diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..651db36 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.DS_Store +output/ +.pytest_cache/ diff --git a/README.md b/README.md index 79818e7..f307cd6 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,48 @@ # Smart Delivery Dispatch System ## Team Information -- **Team Name**: [Team Name] -- **Year**: [Year] -- **All-Female Team**: [Yes/No] +- **Team Name**: TOP-G +- **Year**: 2026 +- **All-Female Team**: No ## Architecture Overview -#### Describe your approach here. Keep it short and clear. - - - 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? +Our system implements a **greedy multi-objective dispatch pipeline** designed for high-throughput real-time operations. +- **Dispatch Strategy**: We use a simulation-clock-driven approach that processes order arrivals in 1-minute ticks. Assignments are made using a priority-first greedy selection. +- **Scoring Logic**: Agents are scored for each order based on a weighted linear combination of five factors: **Delivery Time** (distance + prep), **SLA Risk** (urgency based on deadline), **Workload Fairness** (cumulative assignments), **Priority Boost** (weighted multipliers), and **Agent Rating**. The system selects the candidate that minimizes this composite cost. +- **Management**: + - **SLA/Priority**: A min-heap keyed by `(priority, timestamp)` ensures strict tiered processing. + - **Capacity**: A strict concurrency limit of **2 active orders** per agent is enforced via the Agent Registry. +- **Pipeline**: + 1. **Ingest**: Orders are loaded into a priority heap upon arrival. + 2. **Candidate Generation**: Available agents are filtered by graph connectivity. + 3. **Optimization**: Shortest paths are resolved via **Floyd-Warshall** precomputation for O(1) lookups. + 4. **Execution**: Atomic state updates move orders from `PENDING` to `DELIVERED`. **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. + +## Issue Resolution Summary + +| Issue | Resolution Description | +| :--- | :--- | +| **Issue 1** | Implemented `load_orders` in `src/data_loader.py` with full CSV validation and skip-on-error logic. | +| **Issue 2** | Implemented `load_agents` with rating clamping and graph-location referential integrity checks in `main.py`. | +| **Issue 3** | Built `EnvironmentGraph` in `src/graph.py` supporting Floyd-Warshall precomputation and Dijkstra for routing. | +| **Issue 4** | Created `PriorityOrderQueue` using a min-heap keyed by `(priority, timestamp)` for strict tiered dispatch. | +| **Issue 5** | Developed `AgentRegistry` to track location, workload (max 2), and availability state in O(1). | +| **Issue 6** | Implemented `generate_candidates` in `src/scorer.py` to filter agents by path connectivity and capacity. | +| **Issue 7** | Developed a multi-objective `score_candidates` function using weighted normalization of 5 key factors. | +| **Issue 8** | Added support for tuning objective weights via `constraints.csv` and interactive UI sliders. | +| **Issue 9** | Orchestrated atomic state updates between queue and registry within `Dispatcher._attempt_assignments`. | +| **Issue 10** | Implemented `_complete_delivery` to handle flight removal, agent relocation, and SLA verification. | +| **Issue 11** | Designed `Dispatcher` to auto-retry assignments upon every delivery completion to clear backlogs. | +| **Issue 12** | Integrated Welford's algorithm in `src/metrics.py` for numerically stable online mean tracking. | +| **Issue 13** | Implemented SLA violation tracking and average margin calculation reported by priority tier. | +| **Issue 14** | Added calculation of assignment variance and standard deviation to monitor workload fairness. | +| **Issue 15** | Developed `MetricsCollector.to_json()` for structured exports and a human-readable summary method. | +| **Issue 16** | Robust error handling in `data_loader.py` for missing columns, file errors, and malformed numeric fields. | +| **Issue 17** | Handled disconnected graphs, full agents, and stale deadlines with descriptive logging warnings. | +| **Issue 18** | Achieved sub-millisecond query time via $O(1)$ routing lookups and implemented latency monitoring. | +| **Issue 19** | Optimized processing pipeline to exceed 100 orders/min throughput, tracked via wall-clock time. | +| **Issue 20** | Completed comprehensive documentation of the greedy dispatch strategy and project architecture. | diff --git a/data/raw/constraints.csv b/data/raw/constraints.csv index c420519..90a8eba 100644 --- a/data/raw/constraints.csv +++ b/data/raw/constraints.csv @@ -5,4 +5,8 @@ default_sla_minutes,50 priority_weight_high,1.5 priority_weight_normal,1.0 priority_weight_low,0.8 - +weight_delivery_time,0.35 +weight_sla_risk,0.30 +weight_workload,0.20 +weight_priority,0.10 +weight_rating,0.05 diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..8539733 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,209 @@ +// app.js — UI controller, charts, canvas grid + +// ── Data (embedded from CSVs) ───────────────────────────────────────────── +const ORDERS_RAW = `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`; + +const AGENTS_RAW = `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`; + +// parse +function parseOrders() { + return ORDERS_RAW.trim().split('\n').map(line => { + const [id, ts, x, y, prep, priority, sla] = line.split(','); + return { id, ts, x: +x, y: +y, prep: +prep, priority, sla: +sla }; + }); +} +function parseAgents() { + return AGENTS_RAW.trim().split('\n').map(line => { + const [id, x, y, rating] = line.split(','); + return { id, loc: [+x, +y], rating: +rating }; + }); +} +function buildEdges() { + const edges = []; + for (let x = 0; x <= 9; x++) for (let y = 0; y <= 9; y++) { + if (x + 1 <= 9) edges.push({ from: [x, y], to: [x+1, y], dist: 3, delay: 1.0 }); + if (y + 1 <= 9) edges.push({ from: [x, y], to: [x, y+1], dist: 3, delay: 1.0 }); + } + return edges; +} +const CONSTRAINTS = { + maxActive: 2, + latencyTarget: 5, + defaultSLA: 50, + priHigh: 1.5, + priNormal: 1.0, + priLow: 0.8 +}; diff --git a/frontend/graph.html b/frontend/graph.html new file mode 100644 index 0000000..b54991e --- /dev/null +++ b/frontend/graph.html @@ -0,0 +1,463 @@ + + + + +Graph Analysis — Floyd-Warshall + + + + +
+

📊 Graph Analysis — Floyd-Warshall & Dijkstra

+
+
Graph size:
+ + +
Floyd-Warshall active
+ +
+
+
+ +
+ +
+

🔗 Environment Graph (10×10 Grid)

+
+ +
+
+ +
+

🛤 Shortest Path Finder

FW precomputed — O(1) query
+
+
+ + + + + +
+
Enter coordinates and click Find Path
+
+
ALGORITHM USED
+
+
+
+
STEP-BY-STEP DIJKSTRA TRACE
+
+
+
+
+
+ +
+ +
+
+

📐 Distance Matrix (sample nodes)

+ Heatmap: darker = farther +
+
+
+ +
+

⚙ Floyd-Warshall Steps (k-iteration)

Active for ≤100 nodes
+
+ + + + k = / + Updates: 0 +
+
+
+
+
+ + + + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..06208d8 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,243 @@ + + + + + + Smart Delivery Dispatch — Live Dashboard + + + + + + + + +
+
+
+ TOP-G + Smart Delivery Dispatch System +
+
+ 🗺 Map Sim + 📊 Graph +
+ + Ready +
+
T+00:00
+ + + + +
+
+ + +
+ + + + + +
+ + +
+
+
📦
+
0
+
Delivered
+
of 150 orders
+
+
+
+
+
SLA Compliance
+
0 violations
+
+
+
+
+
Avg Delivery
+
minutes
+
+
+
+
+
Workload Std
+
assignment spread
+
+
+
+
+
Avg Latency
+
ms per decision
+
+
+ + +
+ +
+
+

📈 Delivery Time by Priority

+
+ +
+ + +
+
+

🎯 SLA Compliance

+
+
+ +
+
+
+ On time + Violated +
+
+
+ + +
+
+

📉 Throughput Timeline

+ 0 orders/min +
+ +
+ + +
+
+

⚙ Scoring Weights

+ +
+
+
+ + + 35% +
+
+ + + 30% +
+
+ + + 20% +
+
+ + + 10% +
+
+ + + 5% +
+
+
+ +
+ + + + +
+ + + + + + + + + + diff --git a/frontend/map.html b/frontend/map.html new file mode 100644 index 0000000..636bcf2 --- /dev/null +++ b/frontend/map.html @@ -0,0 +1,419 @@ + + + + + + Map Simulation — Step-by-Step Analysis + + + + + + +
+
+
+

STEP-BY-STEP MAP SIM

+
+ +
+ +
+
+
+ + + +
+ T+0m | Tick 0 +
+ +
+ +
+
+
+ Agent + High + Normal + Low + In-Transit +
+
+ +
+
Assignment Analysis (Numerical)
+
+
Run simulation to see candidate scores...
+
+
+
+ + +
+ + + + diff --git a/frontend/simulation.js b/frontend/simulation.js new file mode 100644 index 0000000..954e694 --- /dev/null +++ b/frontend/simulation.js @@ -0,0 +1,192 @@ +// simulation.js — pure data engine (no DOM) + +const PRIORITY_RANK = { high: 0, normal: 1, low: 2 }; +const INF = Infinity; + +// ── Graph ──────────────────────────────────────────────────────────────────── +class Graph { + constructor(edges) { + this.nodes = new Set(); + this.adj = {}; + this.dist = {}; + for (const e of edges) { + const w = e.dist * e.delay; + this._addEdge(e.from, e.to, w); + this._addEdge(e.to, e.from, w); + } + this._floydWarshall(); + } + _key(loc) { return `${loc[0]},${loc[1]}`; } + _addEdge(a, b, w) { + const ka = this._key(a), kb = this._key(b); + this.nodes.add(ka); + this.nodes.add(kb); + if (!this.adj[ka]) this.adj[ka] = []; + this.adj[ka].push({ to: kb, w }); + } + _floydWarshall() { + const nodes = [...this.nodes]; + const n = nodes.length; + const idx = {}; + nodes.forEach((k, i) => idx[k] = i); + const d = Array.from({ length: n }, () => Array(n).fill(INF)); + for (let i = 0; i < n; i++) d[i][i] = 0; + for (const [src, nbrs] of Object.entries(this.adj)) + for (const { to, w } of nbrs) + if (w < d[idx[src]][idx[to]]) d[idx[src]][idx[to]] = w; + for (let k = 0; k < n; k++) + for (let i = 0; i < n; i++) + for (let j = 0; j < n; j++) + if (d[i][k] + d[k][j] < d[i][j]) d[i][j] = d[i][k] + d[k][j]; + for (let i = 0; i < n; i++) + for (let j = 0; j < n; j++) + this.dist[`${nodes[i]}|${nodes[j]}`] = d[i][j]; + } + travel(a, b) { + if (a[0] === b[0] && a[1] === b[1]) return 0; + return this.dist[`${this._key(a)}|${this._key(b)}`] ?? INF; + } +} + +// ── Scoring ────────────────────────────────────────────────────────────────── +function scoreCandidate(agent, order, travelTime, agents, constraints, weights, elapsed) { + const delivery = travelTime + order.prep; + const maxDelivery = 30; + const maxAssign = Math.max(...agents.map(a => a.assignments), 1); + const pw = { high: constraints.priHigh, normal: constraints.priNormal, low: constraints.priLow }; + const slaRemain = order.sla - (elapsed + delivery); + const slaRatio = Math.max(-1, Math.min(1, slaRemain / order.sla)); + return ( + weights.time * (delivery / maxDelivery) - + weights.sla * slaRatio + + weights.load * (agent.assignments / maxAssign) - + weights.priority * (pw[order.priority] / constraints.priHigh) - + weights.rating * (agent.rating / 5) + ); +} + +// ── Simulator ──────────────────────────────────────────────────────────────── +class Simulator { + constructor(orders, agents, edges, constraints) { + this.allOrders = orders.map(o => ({ ...o, state: 'PENDING', agentId: null })); + this.agents = agents.map(a => ({ ...a, active: [], assignments: 0 })); + this.graph = new Graph(edges); + this.constraints = constraints; + this.weights = { time: .35, sla: .30, load: .20, priority: .10, rating: .05 }; + this.inFlight = []; + this.tick = 0; + this.elapsed = 0; + this.events = []; + this.metrics = { delivered: 0, violations: 0, totalTime: 0, totalSLA: 0, latencies: [] }; + this.throughput = []; // [{tick, count}] + this._tickDelivered = 0; + + const sorted = [...this.allOrders].sort((a, b) => new Date(a.ts) - new Date(b.ts)); + this._arrivals = sorted; + this._startTs = new Date(sorted[0].ts).getTime(); + this._pendingArrivals = [...sorted]; + } + + setWeights(w) { this.weights = { ...this.weights, ...w }; } + + step() { + this.tick++; + this.elapsed++; + this._tickDelivered = 0; + + // Ingest arrivals + const simMs = this._startTs + this.elapsed * 60000; + while (this._pendingArrivals.length && new Date(this._pendingArrivals[0].ts).getTime() <= simMs) { + const o = this._pendingArrivals.shift(); + const found = this.allOrders.find(x => x.id === o.id); + if (found) found.state = 'PENDING'; + } + + // Assign + this._assign(); + + // Advance flights + for (const f of [...this.inFlight]) { + f.remaining -= 1; + if (f.remaining <= 0) this._complete(f); + } + this.inFlight = this.inFlight.filter(f => f.remaining > 0); + + this.throughput.push({ tick: this.tick, count: this._tickDelivered }); + return this._isDone(); + } + + _pendingOrders() { + return this.allOrders.filter(o => o.state === 'PENDING') + .sort((a, b) => PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority] || new Date(a.ts) - new Date(b.ts)); + } + + _availAgents() { return this.agents.filter(a => a.active.length < this.constraints.maxActive); } + + _assign() { + for (const order of this._pendingOrders()) { + const avail = this._availAgents(); + if (!avail.length) break; + + const t0 = performance.now(); + let best = null, bestScore = INF; + for (const agent of avail) { + const t = this.graph.travel(agent.loc, [order.x, order.y]); + if (t === INF) continue; + const s = scoreCandidate(agent, order, t, this.agents, this.constraints, this.weights, this.elapsed); + if (s < bestScore || (s === bestScore && agent.id < (best?.agent.id ?? ''))) { + bestScore = s; best = { agent, t }; + } + } + const latMs = performance.now() - t0; + this.metrics.latencies.push(latMs); + + if (!best) { this._log('warn', `${order.id}: no path — queued`); continue; } + + order.state = 'ASSIGNED'; order.agentId = best.agent.id; + best.agent.active.push(order.id); best.agent.assignments++; + const delivery = Math.ceil(best.t + order.prep); + this.inFlight.push({ orderId: order.id, agentId: best.agent.id, remaining: delivery, dest: [order.x, order.y], sla: order.sla, priority: order.priority, startElapsed: this.elapsed }); + order.state = 'IN_TRANSIT'; + this._log('assign', `${order.id} → ${best.agent.id} (${best.t.toFixed(1)}m travel, ${order.priority})`); + } + } + + _complete(f) { + const order = this.allOrders.find(o => o.id === f.orderId); + const agent = this.agents.find(a => a.id === f.agentId); + const actual = this.elapsed - f.startElapsed; + const violated = actual > f.sla; + if (order) order.state = 'DELIVERED'; + if (agent) { agent.active = agent.active.filter(id => id !== f.orderId); agent.loc = f.dest; } + this.metrics.delivered++; + this.metrics.totalTime += actual; + this.metrics.totalSLA += f.sla; + if (violated) { this.metrics.violations++; this._log('warn', `${f.orderId}: SLA violated (${actual}m > ${f.sla}m)`); } + else this._log('deliver', `${f.orderId} delivered in ${actual}m ✓`); + this._tickDelivered++; + } + + _log(type, msg) { this.events.push({ type, msg, tick: this.tick }); } + + _isDone() { + return this._pendingArrivals.length === 0 && this.allOrders.every(o => o.state === 'DELIVERED'); + } + + exportMetrics() { + const lats = this.metrics.latencies; + const avgLat = lats.length ? lats.reduce((a, b) => a + b, 0) / lats.length : 0; + const assignments = {}; + this.agents.forEach(a => assignments[a.id] = a.assignments); + const vals = Object.values(assignments); + const mean = vals.reduce((a, b) => a + b, 0) / vals.length; + const variance = vals.reduce((s, v) => s + (v - mean) ** 2, 0) / vals.length; + return { + metadata: { timestamp: new Date().toISOString(), total_delivered: this.metrics.delivered, total_violations: this.metrics.violations }, + delivery_time: { overall_mean: +(this.metrics.totalTime / (this.metrics.delivered || 1)).toFixed(2) }, + sla_compliance: { rate: +(1 - this.metrics.violations / (this.metrics.delivered || 1)).toFixed(4), violations: this.metrics.violations }, + workload_fairness: { load_variance: +variance.toFixed(3), load_std: +Math.sqrt(variance).toFixed(3), min: Math.min(...vals), max: Math.max(...vals) }, + decision_latency: { mean_ms: +avgLat.toFixed(3), count: lats.length } + }; + } +} diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..0cf3e82 --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,412 @@ +/* ── Reset & Tokens ─────────────────────────────────── */ +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +:root { + --bg: #07090f; --bg2: #0e1117; --bg3: #141824; --bg4: #1a2030; + --border: #ffffff14; --border2: #ffffff22; + --text: #f0f2f8; --text2: #9aa3b8; --text3: #5a6278; + --accent: #7c6fff; --accent2: #a89fff; + --green: #20d45e; --red: #f04f4f; --orange: #f98c2f; --blue: #4a9eff; --purple: #b06bff; + --high: #f04f4f; --normal: #f98c2f; --low: #20d45e; + --agent: #7c6fff; --delivered: #20d45e; + --radius: 14px; --radius-sm: 8px; + --font: 'Inter', sans-serif; --mono: 'JetBrains Mono', monospace; + --shadow: 0 6px 32px #00000055; + --glass: #ffffff04; --glass-hover: #ffffff0a; +} + +[data-theme="light"] { + --bg: #f5f6f9; --bg2: #ffffff; --bg3: #e8ecf3; --bg4: #dfe4ec; + --border: #00000010; --border2: #00000020; + --text: #1c2235; --text2: #545c75; --text3: #8b92a8; + --shadow: 0 6px 32px #00000011; + --glass: #00000005; --glass-hover: #0000000a; +} + +html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--font);font-size:14px;overflow:hidden} + +/* ── Scrollbar ──────────────────────────────────────── */ +::-webkit-scrollbar{width:4px;height:4px} +::-webkit-scrollbar-track{background:transparent} +::-webkit-scrollbar-thumb{background:var(--border2);border-radius:4px} +::-webkit-scrollbar-thumb:hover{background:#ffffff33} + +/* ── Topbar ─────────────────────────────────────────── */ +.topbar{ + display:flex;align-items:center;justify-content:space-between; + height:56px;padding:0 24px; + background:var(--bg2); + border-bottom:1px solid var(--border); + position:sticky;top:0;z-index:100; + gap:16px; +} +.topbar__brand{display:flex;align-items:center;gap:12px;min-width:0} +.topbar__icon{font-size:22px;flex-shrink:0} +.topbar__title{ + font-size:17px;font-weight:800;letter-spacing:-.5px; + background:linear-gradient(90deg,#7c6fff,#b06bff); + -webkit-background-clip:text;-webkit-text-fill-color:transparent; + white-space:nowrap; +} +.topbar__sub{ + color:var(--text3);font-size:12px; + border-left:1px solid var(--border2);padding-left:12px; + white-space:nowrap; +} +.topbar__controls{display:flex;align-items:center;gap:8px;flex-wrap:nowrap} + +/* Nav links in topbar */ +.topbar__nav-link{ + font-size:12px;font-weight:600;padding:6px 12px; + background:var(--bg3);border:1px solid var(--border2); + border-radius:var(--radius-sm);text-decoration:none; + transition:.2s;white-space:nowrap; +} +.topbar__nav-link:hover{border-color:var(--accent);color:#fff} + +.sim-status{ + display:flex;align-items:center;gap:7px; + padding:5px 14px;background:var(--bg3); + border:1px solid var(--border);border-radius:99px; +} +.sim-status__dot{width:8px;height:8px;border-radius:50%;background:var(--text3);transition:.3s} +.sim-status.running .sim-status__dot{background:var(--green);box-shadow:0 0 8px var(--green);animation:pulse 1.2s infinite} +.sim-status.done .sim-status__dot{background:var(--accent)} +@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}} +.sim-status__label{font-size:12px;font-weight:600;color:var(--text2)} + +.sim-time{ + font-family:var(--mono);font-size:13px;font-weight:600; + color:var(--accent2);padding:6px 12px; + background:var(--bg3);border:1px solid var(--border); + border-radius:var(--radius-sm);letter-spacing:.5px; +} + +/* ── Buttons ─────────────────────────────────────────── */ +.btn{ + border:none;cursor:pointer;border-radius:var(--radius-sm); + font-family:var(--font);font-size:13px;font-weight:600; + padding:8px 18px;transition:.18s;letter-spacing:.2px; + white-space:nowrap; +} +.btn--primary{ + background:linear-gradient(135deg,var(--accent),#9d7fff); + color:#fff;box-shadow:0 2px 14px #7c6fff33; +} +.btn--primary:hover{transform:translateY(-1px);box-shadow:0 4px 22px #7c6fff55} +.btn--primary:disabled{opacity:.35;cursor:not-allowed;transform:none} +.btn--ghost{ + background:var(--bg3);color:var(--text2); + border:1px solid var(--border2); +} +.btn--ghost:hover{border-color:var(--accent);color:var(--text)} +.btn--sm{padding:5px 11px;font-size:11px} + +/* ── Layout ─────────────────────────────────────────── */ +.layout{ + display:grid;grid-template-columns:400px 1fr 300px; + height:calc(100vh - 56px);overflow:hidden; +} +.panel{ + display:flex;flex-direction:column;gap:10px; + padding:12px;overflow-y:auto;overflow-x:hidden; +} +.panel--left{border-right:1px solid var(--border);background:var(--bg)} +.panel--centre{background:var(--bg);padding:12px 14px} +.panel--right{border-left:1px solid var(--border);background:var(--bg)} + +/* ── Cards ───────────────────────────────────────────── */ +.card{ + background:var(--bg2);border:1px solid var(--border); + border-radius:var(--radius);overflow:hidden; +} +.card__header{ + display:flex;align-items:center;justify-content:space-between; + padding:11px 16px;border-bottom:1px solid var(--border); + background:var(--bg3); +} +.card__title{ + font-size:13px;font-weight:700;color:var(--text); + letter-spacing:.1px;display:flex;align-items:center;gap:6px; +} +.card__badge{ + font-size:11px;font-weight:700;padding:3px 10px; + background:var(--bg);border:1px solid var(--border2); + border-radius:99px;color:var(--text2); +} + +/* ── Map Card ────────────────────────────────────────── */ +.card--map{flex-shrink:0} +.map-wrapper{display:flex;justify-content:center;padding:10px;background:var(--bg)} +#gridCanvas{border-radius:8px;border:1px solid var(--border)} +.map-legend{ + display:flex;align-items:center;gap:10px; + font-size:11px;color:var(--text2);font-weight:500; +} +.legend-dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex-shrink:0} +.legend-dot--agent{background:var(--agent)} +.legend-dot--order-high{background:var(--high)} +.legend-dot--order-normal{background:var(--normal)} +.legend-dot--order-low{background:var(--low)} +.legend-dot--delivered{background:var(--delivered)} +.map-tick-info{ + display:flex;gap:16px;padding:8px 16px; + border-top:1px solid var(--border); + font-size:11px;color:var(--text3);font-family:var(--mono); + background:var(--bg3); +} +.map-tick-info span{display:flex;align-items:center;gap:4px} + +/* ── Order Queue ─────────────────────────────────────── */ +.card--queue{display:flex;flex-direction:column;min-height:0;flex:1} +.queue-filters{display:flex;gap:4px;flex-wrap:wrap} +.filter-btn{ + border:none;cursor:pointer;padding:4px 10px;border-radius:99px; + font-size:11px;font-weight:600;background:var(--bg); + color:var(--text3);transition:.2s;border:1px solid transparent; +} +.filter-btn:hover{color:var(--text2);border-color:var(--border2)} +.filter-btn.active{background:var(--accent);color:#fff;border-color:transparent} +.queue-list{flex:1;overflow-y:auto;padding:8px} + +.order-item{ + display:flex;align-items:center;gap:8px; + padding:8px 10px;border-radius:var(--radius-sm); + border:1px solid transparent;margin-bottom:5px; + transition:.2s;cursor:default; +} +.order-item:hover{background:var(--bg3)} +.order-item--PENDING{border-color:var(--border)} +.order-item--ASSIGNED{border-color:#4a9eff28;background:#4a9eff08} +.order-item--IN_TRANSIT{ + border-color:#f98c2f44;background:#f98c2f08; + animation:glow-transit 2s infinite; +} +.order-item--DELIVERED{border-color:#20d45e22;background:#20d45e08;opacity:.55} +@keyframes glow-transit{0%,100%{box-shadow:none}50%{box-shadow:0 0 8px #f98c2f22}} + +.order-badge{font-size:10px;font-weight:700;padding:2px 8px;border-radius:99px;white-space:nowrap} +.order-badge--high{background:#f04f4f18;color:var(--high);border:1px solid #f04f4f33} +.order-badge--normal{background:#f98c2f18;color:var(--orange);border:1px solid #f98c2f33} +.order-badge--low{background:#20d45e18;color:var(--green);border:1px solid #20d45e33} + +.order-id{font-family:var(--mono);font-size:11px;font-weight:700;color:var(--accent2);min-width:46px} +.order-loc{font-size:11px;color:var(--text3)} +.order-state{font-size:10px;font-weight:700;padding:2px 7px;border-radius:5px;margin-left:auto} +.state--PENDING{background:#ffffff0e;color:var(--text2)} +.state--ASSIGNED{background:#4a9eff1a;color:var(--blue)} +.state--IN_TRANSIT{background:#f98c2f1a;color:var(--orange)} +.state--DELIVERED{background:#20d45e1a;color:var(--green)} + +/* ── KPI Row ─────────────────────────────────────────── */ +.kpi-row{display:grid;grid-template-columns:repeat(5,1fr);gap:10px} +.kpi-card{ + background:var(--bg2);border:1px solid var(--border); + border-radius:var(--radius);padding:14px 10px; + text-align:center;position:relative;overflow:hidden; + transition:.2s; +} +.kpi-card::after{ + content:'';position:absolute;bottom:0;left:0;right:0; + height:2px;background:var(--accent);opacity:.4; +} +.kpi-card--green::after{background:var(--green)} +.kpi-card--blue::after{background:var(--blue)} +.kpi-card--purple::after{background:var(--purple)} +.kpi-card--orange::after{background:var(--orange)} +.kpi-card__icon{font-size:20px;margin-bottom:6px;line-height:1} +.kpi-card__value{ + font-size:24px;font-weight:800;font-family:var(--mono); + letter-spacing:-1px;line-height:1;color:var(--text); +} +.kpi-card--green .kpi-card__value{color:var(--green)} +.kpi-card--blue .kpi-card__value{color:var(--blue)} +.kpi-card--purple .kpi-card__value{color:var(--purple)} +.kpi-card--orange .kpi-card__value{color:var(--orange)} +.kpi-card__label{font-size:11px;color:var(--text2);margin-top:5px;font-weight:600} +.kpi-card__sub{font-size:10px;color:var(--text3);margin-top:2px} + +/* ── Charts ─────────────────────────────────────────── */ +.charts-row{display:grid;grid-template-columns:1fr 190px;gap:10px} +.card--chart{padding:0} +.card--chart .card__header{margin-bottom:0} +.card--chart canvas{display:block;padding:10px 14px 12px} +.card--full{padding:0} +.card--full canvas{display:block;padding:8px 14px 12px} +.card--ring{display:flex;flex-direction:column;align-items:center} +.ring-wrapper{ + position:relative;display:flex; + justify-content:center;align-items:center;margin:8px 0; +} +.ring-centre{ + position:absolute;font-size:20px;font-weight:800; + font-family:var(--mono);color:var(--text); +} +.ring-legend{ + display:flex;gap:12px;font-size:11px; + color:var(--text2);padding-bottom:8px;font-weight:500; +} +.ring-legend__dot{ + width:9px;height:9px;border-radius:50%; + display:inline-block;margin-right:3px; +} +.ring-legend__dot--ok{background:var(--green)} +.ring-legend__dot--bad{background:var(--red)} + +/* ── Weights ─────────────────────────────────────────── */ +.card--weights{} +.weights-grid{display:flex;flex-direction:column;gap:10px;padding:12px 16px} +.weight-item{display:grid;grid-template-columns:110px 1fr 42px;align-items:center;gap:12px} +.weight-item label{font-size:12px;color:var(--text2);font-weight:600} +.weight-item input[type=range]{ + -webkit-appearance:none;appearance:none; + height:4px;border-radius:4px;background:var(--bg3); + outline:none;cursor:pointer; +} +.weight-item input[type=range]::-webkit-slider-thumb{ + -webkit-appearance:none;width:15px;height:15px; + border-radius:50%;background:var(--accent); + cursor:pointer;box-shadow:0 0 8px var(--accent)66; +} +.weight-item span{ + font-family:var(--mono);font-size:12px;font-weight:700; + color:var(--accent2);text-align:right; +} + +/* ── Agent Grid ─────────────────────────────────────── */ +.card--agents{flex-shrink:0} +.agents-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:6px;padding:10px} +.agent-cell{ + border-radius:var(--radius-sm);padding:8px 4px; + text-align:center;border:1px solid transparent; + transition:.25s;cursor:pointer;position:relative; +} +.agent-cell:hover{transform:scale(1.1);z-index:1;box-shadow:0 0 14px #7c6fff44} +.agent-cell--available{background:var(--bg3);border-color:var(--border)} +.agent-cell--busy{background:#7c6fff14;border-color:#7c6fff44} +.agent-cell--full{background:#f04f4f14;border-color:#f04f4f44} +.agent-cell--has-log{box-shadow:inset 0 0 0 1px #7c6fff55} +.agent-cell__id{font-size:9px;font-family:var(--mono);font-weight:700;color:var(--text3)} +.agent-cell__load{font-size:13px;font-weight:800;color:var(--text);margin:2px 0} +.agent-cell--available .agent-cell__load{color:var(--green)} +.agent-cell--busy .agent-cell__load{color:var(--accent2)} +.agent-cell--full .agent-cell__load{color:var(--red)} +.agent-cell__rating{font-size:9px;color:var(--text3)} +.agent-cell__dot{ + width:5px;height:5px;border-radius:50%; + background:var(--accent);margin:3px auto 0; + animation:pulse 1.5s infinite; +} + +/* ── Workload ────────────────────────────────────────── */ +.card--workload{padding:0} +.card--workload canvas{display:block;padding:8px 14px 12px} + +/* ── Log ─────────────────────────────────────────────── */ +.card--log{display:flex;flex-direction:column;flex:1;min-height:120px} +.log-list{ + flex:1;overflow-y:auto;padding:8px; + display:flex;flex-direction:column;gap:3px; +} +.log-item{ + font-size:11px;font-family:var(--mono); + padding:4px 9px;border-radius:5px; + border-left:2px solid transparent; + line-height:1.5;word-break:break-word; +} +.log-item--assign{border-color:var(--accent);background:#7c6fff0d;color:var(--accent2)} +.log-item--deliver{border-color:var(--green);background:#20d45e0d;color:var(--green)} +.log-item--warn{border-color:var(--orange);background:#f98c2f0d;color:var(--orange)} +.log-item--error{border-color:var(--red);background:#f04f4f0d;color:var(--red)} +.log-item--info{border-color:var(--border);color:var(--text2)} + +/* ── Agent Tooltip ───────────────────────────────────── */ +.agent-tooltip{ + position:fixed;width:280px; + background:var(--bg2);border:1px solid var(--border2); + border-radius:var(--radius); + box-shadow:0 12px 40px #00000088,0 0 0 1px #7c6fff22; + z-index:500;overflow:hidden;animation:fadeIn .15s ease; +} +@keyframes fadeIn{from{opacity:0;transform:translateY(-5px)}to{opacity:1;transform:translateY(0)}} +.atp-header{ + display:flex;align-items:center;gap:8px; + padding:11px 14px;border-bottom:1px solid var(--border); + background:var(--bg3); +} +.atp-id{font-family:var(--mono);font-size:14px;font-weight:800;color:var(--accent2)} +.atp-status{font-size:11px;font-weight:700;margin-left:auto} +.atp-close{ + background:none;border:none;color:var(--text2); + cursor:pointer;font-size:15px;padding:0 3px;line-height:1; +} +.atp-close:hover{color:var(--text)} +.atp-meta{ + display:flex;gap:10px;flex-wrap:wrap; + padding:9px 14px;border-bottom:1px solid var(--border); + font-size:11px;color:var(--text2);font-weight:500; +} +.atp-log-title{ + padding:7px 14px 4px;font-size:10px;font-weight:700; + text-transform:uppercase;letter-spacing:.6px;color:var(--text3); +} +.atp-log{max-height:190px;overflow-y:auto;padding:0 10px 10px} +.atp-empty{font-size:11px;color:var(--text3);padding:8px 4px;display:block} +.atp-ev{ + font-family:var(--mono);font-size:9px;padding:3px 5px; + border-left:2px solid transparent;line-height:1.5; + word-break:break-word;border-radius:0 4px 4px 0;margin-bottom:3px; +} +.atp-ev--assign{border-color:var(--accent);color:var(--accent2);background:#7c6fff0a} +.atp-ev--deliver{border-color:var(--green);color:var(--green);background:#20d45e0a} +.atp-ev--warn{border-color:var(--orange);color:var(--orange);background:#f98c2f0a} +.atp-ev--info{border-color:var(--border);color:var(--text2)} + +/* ── Export FAB & Modal ─────────────────────────────── */ +.fab{ + position:fixed;bottom:22px;right:22px; + width:50px;height:50px;border-radius:50%;border:none; + background:linear-gradient(135deg,var(--accent),#9d7fff); + font-size:20px;cursor:pointer; + box-shadow:0 4px 20px #7c6fff55;transition:.2s;z-index:200; +} +.fab:hover{transform:scale(1.12);box-shadow:0 8px 32px #7c6fff77} + +.modal-overlay{ + position:fixed;inset:0;background:#000a; + display:none;align-items:center;justify-content:center; + z-index:300;backdrop-filter:blur(6px); +} +.modal-overlay.open{display:flex} +.modal { + background: var(--bg2); + border: 1px solid var(--border2); + border-radius: var(--radius); + width: 580px; + max-height: 82vh; + display: flex; + flex-direction: column; + box-shadow: var(--shadow); +} +.modal__header{ + display:flex;align-items:center;justify-content:space-between; + padding:16px 20px;border-bottom:1px solid var(--border); +} +.modal__header h3{font-size:14px;font-weight:700} +.modal__close{ + background:none;border:none;color:var(--text2); + cursor:pointer;font-size:17px;padding:0 4px; +} +.modal__code{ + flex:1;overflow-y:auto;padding:16px 20px; + font-family:var(--mono);font-size:12px;color:#7dd3fc; + line-height:1.7;white-space:pre-wrap;background:var(--bg); +} +.modal__footer{ + display:flex;gap:10px;padding:14px 20px; + border-top:1px solid var(--border); +} + +/* ── Progress bar in KPI ────────────────────────────── */ +.kpi-card__bar{height:3px;border-radius:3px;margin-top:8px;background:var(--bg3);overflow:hidden} +.kpi-card__bar-fill{height:100%;border-radius:3px;transition:width .5s ease;background:linear-gradient(90deg,var(--accent),var(--accent2))} +.kpi-card--green .kpi-card__bar-fill{background:linear-gradient(90deg,var(--green),#4ade80)} diff --git a/frontend/ui.js b/frontend/ui.js new file mode 100644 index 0000000..fe65074 --- /dev/null +++ b/frontend/ui.js @@ -0,0 +1,584 @@ +// ui.js — DOM rendering, canvas, charts, controls + +// ── Canvas Grid ─────────────────────────────────────────────────────────────── +const CELL = 36; +const PAD = 8; + +function drawGrid(canvas, sim) { + const ctx = canvas.getContext('2d'); + const size = 10; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // background + ctx.fillStyle = '#0f1320'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // grid lines + ctx.strokeStyle = '#ffffff08'; + ctx.lineWidth = 1; + for (let i = 0; i <= size; i++) { + ctx.beginPath(); + ctx.moveTo(PAD + i * CELL, PAD); + ctx.lineTo(PAD + i * CELL, PAD + size * CELL); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(PAD, PAD + i * CELL); + ctx.lineTo(PAD + size * CELL, PAD + i * CELL); + ctx.stroke(); + } + + // cell coords + ctx.fillStyle = '#ffffff18'; + ctx.font = '8px JetBrains Mono, monospace'; + ctx.textAlign = 'center'; + for (let x = 0; x < size; x++) + for (let y = 0; y < size; y++) { + const cx = PAD + x * CELL + CELL / 2; + const cy = PAD + y * CELL + CELL / 2; + ctx.fillText(`${x},${y}`, cx, cy + 3); + } + + if (!sim) return; + + // in-transit paths (faint lines agent→order) + for (const f of sim.inFlight) { + const agent = sim.agents.find(a => a.id === f.agentId); + if (!agent) continue; + const [ax, ay] = agent.loc; + const [ox, oy] = f.dest; + ctx.strokeStyle = '#f9731633'; + ctx.lineWidth = 1.5; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.moveTo(PAD + ax * CELL + CELL / 2, PAD + ay * CELL + CELL / 2); + ctx.lineTo(PAD + ox * CELL + CELL / 2, PAD + oy * CELL + CELL / 2); + ctx.stroke(); + ctx.setLineDash([]); + } + + // orders + const priColor = { high: '#ef4444', normal: '#f97316', low: '#22c55e' }; + for (const o of sim.allOrders) { + if (o.state === 'DELIVERED') continue; + const cx = PAD + o.x * CELL + CELL / 2; + const cy = PAD + o.y * CELL + CELL / 2; + const col = priColor[o.priority]; + // outer glow + ctx.beginPath(); + ctx.arc(cx, cy, 9, 0, Math.PI * 2); + ctx.fillStyle = col + '33'; + ctx.fill(); + ctx.beginPath(); + ctx.arc(cx, cy, 5, 0, Math.PI * 2); + ctx.fillStyle = col; + ctx.fill(); + if (o.state === 'IN_TRANSIT') { + ctx.strokeStyle = '#f9731699'; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + } + + // agents + for (const agent of sim.agents) { + const [ax, ay] = agent.loc; + const cx = PAD + ax * CELL + CELL / 2; + const cy = PAD + ay * CELL + CELL / 2; + const busy = agent.active.length > 0; + ctx.beginPath(); + ctx.arc(cx - 6, cy - 6, 6, 0, Math.PI * 2); + ctx.fillStyle = busy ? '#6c63ffcc' : '#6c63ff55'; + ctx.fill(); + ctx.strokeStyle = busy ? '#a78bfa' : '#6c63ff88'; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.fillStyle = '#fff'; + ctx.font = 'bold 7px Inter, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(agent.id.replace('A', ''), cx - 6, cy - 3); + } +} + +// ── Mini Bar Chart ──────────────────────────────────────────────────────────── +function drawBarChart(canvas, labels, values, color = '#6c63ff') { + const ctx = canvas.getContext('2d'); + const W = canvas.width, H = canvas.height; + ctx.clearRect(0, 0, W, H); + ctx.fillStyle = '#0f1320'; + ctx.fillRect(0, 0, W, H); + + if (!values.length) return; + const max = Math.max(...values, 1); + const bw = Math.max(2, (W - 20) / values.length - 2); + + values.forEach((v, i) => { + const bh = ((v / max) * (H - 30)); + const bx = 10 + i * ((W - 20) / values.length); + const by = H - 20 - bh; + const grad = ctx.createLinearGradient(0, by, 0, H - 20); + grad.addColorStop(0, color + 'ee'); + grad.addColorStop(1, color + '33'); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.roundRect(bx, by, bw, bh, 2); + ctx.fill(); + }); + // x-axis label at start/end + ctx.fillStyle = '#8b92a8'; + ctx.font = '9px Inter, sans-serif'; + ctx.textAlign = 'left'; + if (labels.length) ctx.fillText(labels[0], 10, H - 5); + ctx.textAlign = 'right'; + if (labels.length > 1) ctx.fillText(labels[labels.length - 1], W - 10, H - 5); +} + +// ── Donut Chart ─────────────────────────────────────────────────────────────── +function drawDonut(canvas, ok, bad, textEl) { + const ctx = canvas.getContext('2d'); + const W = canvas.width, H = canvas.height; + ctx.clearRect(0, 0, W, H); + const cx = W / 2, cy = H / 2, r = Math.min(cx, cy) - 10, thick = 22; + const total = ok + bad || 1; + const okAngle = (ok / total) * Math.PI * 2; + + // track + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.strokeStyle = '#1c2235'; + ctx.lineWidth = thick; + ctx.stroke(); + + // ok + ctx.beginPath(); + ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + okAngle); + ctx.strokeStyle = '#22c55e'; + ctx.lineWidth = thick; + ctx.lineCap = 'round'; + ctx.stroke(); + + // bad + if (bad > 0) { + ctx.beginPath(); + ctx.arc(cx, cy, r, -Math.PI / 2 + okAngle, -Math.PI / 2 + Math.PI * 2); + ctx.strokeStyle = '#ef4444'; + ctx.lineWidth = thick; + ctx.lineCap = 'round'; + ctx.stroke(); + } + + let pct = (ok / total) * 100; + // Never show 100% when there are violations + if (bad > 0) pct = Math.min(pct, 99.9); + if (textEl) textEl.textContent = `${pct.toFixed(1)}%`; +} + +// ── Workload Bars ───────────────────────────────────────────────────────────── +function drawWorkload(canvas, agents) { + const ctx = canvas.getContext('2d'); + const W = canvas.width, H = canvas.height; + ctx.clearRect(0, 0, W, H); + ctx.fillStyle = '#0f1320'; + ctx.fillRect(0, 0, W, H); + + const max = Math.max(...agents.map(a => a.assignments), 1); + const bw = (W - 16) / agents.length - 1; + + agents.forEach((a, i) => { + const bh = ((a.assignments / max) * (H - 28)); + const bx = 8 + i * ((W - 16) / agents.length); + const by = H - 22 - bh; + const busy = a.active.length > 0; + ctx.fillStyle = busy ? '#6c63ffcc' : '#6c63ff55'; + ctx.beginPath(); + ctx.roundRect(bx, by, bw, bh, 2); + ctx.fill(); + }); + + ctx.fillStyle = '#545c75'; + ctx.font = '8px Inter, sans-serif'; + ctx.textAlign = 'center'; + agents.forEach((a, i) => { + const bx = 8 + i * ((W - 16) / agents.length) + bw / 2; + ctx.fillText(a.id.replace('A0', '').replace('A', ''), bx, H - 8); + }); +} + +// ── Order Queue List ────────────────────────────────────────────────────────── +let _queueFilter = 'all'; +function renderOrderQueue(sim, filter) { + if (filter) _queueFilter = filter; + const el = document.getElementById('orderQueue'); + if (!el || !sim) return; + const orders = sim.allOrders.filter(o => _queueFilter === 'all' || o.state === _queueFilter); + el.innerHTML = orders.map(o => ` +
+ ${o.id} + ${o.priority} + (${o.x},${o.y}) + ${o.agentId ? `${o.agentId}` : ''} + ${o.state.replace('_', ' ')} +
+ `).join(''); +} + +// ── Per-agent log store ─────────────────────────────────────────────────────── +const agentLogs = {}; // { agentId: [{tick, type, msg}] } + +function trackAgentEvents(sim) { + // scan new events and bucket by agent id + const newEvents = sim.events.slice(_lastAgentEventIdx || 0); + _lastAgentEventIdx = sim.events.length; + for (const ev of newEvents) { + // extract agent id from messages like "O001 → A003 …" or "O001 delivered by A003" + const m = ev.msg.match(/→\s*(A\d+)/) || ev.msg.match(/by\s+(A\d+)/); + if (m) { + const aid = m[1]; + if (!agentLogs[aid]) agentLogs[aid] = []; + agentLogs[aid].push({ tick: ev.tick, type: ev.type, msg: ev.msg }); + } + } +} +let _lastAgentEventIdx = 0; + +// ── Agent Tooltip ────────────────────────────────────────────────────────────── +function showAgentTooltip(agentId, cellEl, sim) { + // remove existing + document.querySelectorAll('.agent-tooltip').forEach(t => t.remove()); + + const agent = sim ? sim.agents.find(a => a.id === agentId) : null; + const logs = agentLogs[agentId] || []; + const status = agent ? (agent.active.length === 0 ? 'available' : agent.active.length < 2 ? 'busy' : 'full') : 'unknown'; + const statusColor = { available: '#22c55e', busy: '#a78bfa', full: '#ef4444', unknown: '#8b92a8' }[status]; + + const tip = document.createElement('div'); + tip.className = 'agent-tooltip'; + tip.innerHTML = ` +
+ ${agentId} + ${status.toUpperCase()} + +
+
+ 📍 (${agent ? agent.loc.join(',') : '?'}) + ⭐ ${agent ? agent.rating : '?'} + 📦 ${agent ? agent.assignments : 0} assigned + 🔄 ${agent ? agent.active.length : 0} active +
+
Event History (${logs.length})
+
${ + logs.length === 0 + ? 'No events yet' + : logs.slice(-20).reverse().map(e => + `
[T${String(e.tick).padStart(3,'0')}] ${e.msg}
` + ).join('') + }
`; + + // position near cell + const rect = cellEl.getBoundingClientRect(); + const panelRect = document.querySelector('.panel--right').getBoundingClientRect(); + tip.style.top = `${rect.top + window.scrollY - 4}px`; + tip.style.left = `${panelRect.left - 280}px`; + document.body.appendChild(tip); + + // close on outside click + setTimeout(() => { + document.addEventListener('click', function outside(e) { + if (!tip.contains(e.target)) { tip.remove(); document.removeEventListener('click', outside); } + }); + }, 100); +} + +// ── Agent Grid ──────────────────────────────────────────────────────────────── +function renderAgentGrid(sim) { + const el = document.getElementById('agentGrid'); + if (!el || !sim) return; + const avail = sim.agents.filter(a => a.active.length === 0).length; + document.getElementById('agentAvailBadge').textContent = `${avail} available`; + el.innerHTML = sim.agents.map(a => { + const cls = a.active.length === 0 ? 'available' : a.active.length < 2 ? 'busy' : 'full'; + const hasLog = (agentLogs[a.id] || []).length > 0; + return ` +
+
${a.id}
+
${a.assignments}
+
★${a.rating}
+ ${hasLog ? '
' : ''} +
`; + }).join(''); + + // wire click handlers + el.querySelectorAll('.agent-cell').forEach(cell => { + cell.addEventListener('click', function(e) { + e.stopPropagation(); + showAgentTooltip(this.dataset.aid, this, sim); + }); + }); +} + +// ── Event Log ───────────────────────────────────────────────────────────────── +let _lastLogIdx = 0; +function appendLogs(sim) { + const el = document.getElementById('eventLog'); + if (!el || !sim) return; + const newEvents = sim.events.slice(_lastLogIdx); + _lastLogIdx = sim.events.length; + for (const ev of newEvents) { + const div = document.createElement('div'); + div.className = `log-item log-item--${ev.type}`; + div.textContent = `[T${String(ev.tick).padStart(3,'0')}] ${ev.msg}`; + el.prepend(div); + } + // cap at 200 items + while (el.children.length > 200) el.removeChild(el.lastChild); +} + +// ── KPI Update ──────────────────────────────────────────────────────────────── +function updateKPIs(sim) { + if (!sim) return; + const m = sim.metrics; + const total = sim.allOrders.length; + + setText('kpiDeliveredVal', m.delivered); + setText('kpiDeliveredSub', `of ${total} orders`); + + let compliance = m.delivered ? ((m.delivered - m.violations) / m.delivered * 100) : null; + if (compliance !== null && m.violations > 0) compliance = Math.min(compliance, 99.9); + setText('kpiSLAVal', compliance !== null ? `${compliance.toFixed(1)}%` : '—'); + setText('kpiSLASub', `${m.violations} violations`); + + const avgT = m.delivered ? (m.totalTime / m.delivered).toFixed(1) : '—'; + setText('kpiAvgTimeVal', avgT); + + const vals = sim.agents.map(a => a.assignments); + const mean = vals.reduce((a, b) => a + b, 0) / vals.length; + const std = Math.sqrt(vals.reduce((s, v) => s + (v - mean) ** 2, 0) / vals.length); + setText('kpiFairnessVal', std.toFixed(2)); + + const lats = m.latencies; + const avgLat = lats.length ? (lats.reduce((a, b) => a + b, 0) / lats.length).toFixed(3) : '—'; + setText('kpiLatencyVal', avgLat === '—' ? '—' : `${avgLat}`); + + // donut + const ok = m.delivered - m.violations; + drawDonut(document.getElementById('chartSLA'), ok, m.violations, document.getElementById('ringCentreText')); + + // throughput badge + const recent = sim.throughput.slice(-60).reduce((s, t) => s + t.count, 0); + setText('throughputBadge', `~${recent} orders/min`); + + // map tick info + setText('mapTick', `Tick: ${sim.tick}`); + setText('mapPending', `Pending: ${sim.allOrders.filter(o => o.state === 'PENDING').length}`); + setText('mapInFlight', `In-Transit: ${sim.inFlight.length}`); +} + +function setText(id, val) { + const el = document.getElementById(id); + if (el) el.textContent = val; +} + +// ── Throughput Chart ────────────────────────────────────────────────────────── +let _tpHistory = []; +function updateThroughputChart(sim) { + if (!sim || !sim.throughput.length) return; + _tpHistory = sim.throughput.slice(-120).map(t => t.count); + const canvas = document.getElementById('chartThroughput'); + drawBarChart(canvas, [], _tpHistory, '#6c63ff'); +} + +// ── Delivery Chart ──────────────────────────────────────────────────────────── +function updateDeliveryChart(sim) { + if (!sim) return; + const canvas = document.getElementById('chartDelivery'); + const ctx = canvas.getContext('2d'); + const W = canvas.width, H = canvas.height; + ctx.clearRect(0, 0, W, H); + + const priorities = ['high', 'normal', 'low']; + const colors = ['#ef4444', '#f97316', '#22c55e']; + const delivered = sim.allOrders.filter(o => o.state === 'DELIVERED'); + + const byPri = {}; + for (const p of priorities) { + const orders = delivered.filter(o => o.priority === p); + byPri[p] = orders.length; + } + + const max = Math.max(...Object.values(byPri), 1); + const bw = (W - 40) / 3; + ctx.fillStyle = '#0f1320'; + ctx.fillRect(0, 0, W, H); + + priorities.forEach((p, i) => { + const bh = ((byPri[p] / max) * (H - 40)); + const bx = 20 + i * bw + bw * .15; + const by = H - 30 - bh; + const grad = ctx.createLinearGradient(0, by, 0, H - 30); + grad.addColorStop(0, colors[i] + 'dd'); + grad.addColorStop(1, colors[i] + '22'); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.roundRect(bx, by, bw * .7, bh, 4); + ctx.fill(); + ctx.fillStyle = colors[i]; + ctx.font = 'bold 12px Inter'; + ctx.textAlign = 'center'; + ctx.fillText(byPri[p], bx + bw * .35, by - 6); + ctx.fillStyle = '#8b92a8'; + ctx.font = '10px Inter'; + ctx.fillText(p, bx + bw * .35, H - 12); + }); +} + +// ── Main Controller ─────────────────────────────────────────────────────────── +let sim = null; +let _animId = null; +let _speed = 1; +let _speedModes = [1, 5, 20]; +let _speedIdx = 0; + +const canvas = document.getElementById('gridCanvas'); + +function initSim() { + _lastLogIdx = 0; + _tpHistory = []; + sim = new Simulator(parseOrders(), parseAgents(), buildEdges(), CONSTRAINTS); + renderOrderQueue(sim); + renderAgentGrid(sim); + drawGrid(canvas, sim); + updateKPIs(sim); + setStatus('Ready', ''); +} + +function setStatus(label, cls) { + const el = document.getElementById('simStatus'); + const lbl = el.querySelector('.sim-status__label'); + el.className = 'sim-status ' + cls; + lbl.textContent = label; +} + +function fmtTime(ticks) { + const h = String(Math.floor(ticks / 60)).padStart(2, '0'); + const m = String(ticks % 60).padStart(2, '0'); + return `T+${h}:${m}`; +} + +function tick() { + if (!sim) return; + let steps = _speed; + let done = false; + for (let i = 0; i < steps; i++) { + done = sim.step(); + if (done) break; + } + document.getElementById('simTimeDisplay').textContent = fmtTime(sim.elapsed); + drawGrid(canvas, sim); + renderOrderQueue(sim); + renderAgentGrid(sim); + updateKPIs(sim); + updateThroughputChart(sim); + updateDeliveryChart(sim); + drawWorkload(document.getElementById('chartWorkload'), sim.agents); + appendLogs(sim); + trackAgentEvents(sim); + + if (done) { + setStatus('Complete', 'done'); + document.getElementById('btnRun').disabled = true; + return; + } + _animId = requestAnimationFrame(tick); +} + +function startSim() { + if (_animId) cancelAnimationFrame(_animId); + setStatus('Running', 'running'); + document.getElementById('btnRun').disabled = true; + _animId = requestAnimationFrame(tick); +} + +// ── Controls ────────────────────────────────────────────────────────────────── +document.getElementById('btnRun').addEventListener('click', startSim); + +document.getElementById('btnReset').addEventListener('click', () => { + if (_animId) cancelAnimationFrame(_animId); + _animId = null; + document.getElementById('btnRun').disabled = false; + document.getElementById('eventLog').innerHTML = ''; + Object.keys(agentLogs).forEach(k => delete agentLogs[k]); + _lastAgentEventIdx = 0; + document.querySelectorAll('.agent-tooltip').forEach(t => t.remove()); + initSim(); +}); + +document.getElementById('btnSpeed').addEventListener('click', function () { + _speedIdx = (_speedIdx + 1) % _speedModes.length; + _speed = _speedModes[_speedIdx]; + this.textContent = `${_speed}×`; +}); + +document.getElementById('btnClearLog').addEventListener('click', () => { + document.getElementById('eventLog').innerHTML = ''; +}); + +// Theme toggle +document.getElementById('btnTheme').addEventListener('click', () => { + const root = document.documentElement; + const theme = root.getAttribute('data-theme') === 'light' ? 'dark' : 'light'; + root.setAttribute('data-theme', theme); + localStorage.setItem('dispatch-theme', theme); +}); +const savedTheme = localStorage.getItem('dispatch-theme'); +if (savedTheme) document.documentElement.setAttribute('data-theme', savedTheme); + +// Queue filters +document.querySelectorAll('.filter-btn').forEach(btn => { + btn.addEventListener('click', function () { + document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); + this.classList.add('active'); + renderOrderQueue(sim, this.dataset.filter); + }); +}); + +// Weight sliders +['Time','SLA','Load','Pri','Rating'].forEach(key => { + const input = document.getElementById(`w${key}`); + const label = document.getElementById(`w${key}Val`); + if (input && label) { + input.addEventListener('input', () => { label.textContent = `${input.value}%`; }); + } +}); +document.getElementById('btnApplyWeights').addEventListener('click', () => { + if (!sim) return; + const get = id => parseInt(document.getElementById(id).value) / 100; + sim.setWeights({ time: get('wTime'), sla: get('wSLA'), load: get('wLoad'), priority: get('wPri'), rating: get('wRating') }); + addLog('info', 'Scoring weights updated'); +}); +function addLog(type, msg) { + if (!sim) return; + sim._log(type, msg); + appendLogs(sim); +} + +// Export modal +document.getElementById('btnExport').addEventListener('click', () => { + const modal = document.getElementById('exportModal'); + modal.classList.add('open'); + const data = sim ? sim.exportMetrics() : {}; + document.getElementById('exportJSON').textContent = JSON.stringify(data, null, 2); +}); +['btnCloseModal','btnCloseModal2'].forEach(id => { + document.getElementById(id).addEventListener('click', () => { + document.getElementById('exportModal').classList.remove('open'); + }); +}); +document.getElementById('btnDownloadJSON').addEventListener('click', () => { + const data = sim ? sim.exportMetrics() : {}; + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); a.href = url; a.download = 'metrics.json'; a.click(); + URL.revokeObjectURL(url); +}); + +// ── Boot ────────────────────────────────────────────────────────────────────── +window.addEventListener('load', initSim); diff --git a/images/Path-tracing.png b/images/Path-tracing.png new file mode 100644 index 0000000..fa659c0 Binary files /dev/null and b/images/Path-tracing.png differ diff --git a/images/algo-map.png b/images/algo-map.png new file mode 100644 index 0000000..e6353d7 Binary files /dev/null and b/images/algo-map.png differ diff --git a/images/at-end.png b/images/at-end.png new file mode 100644 index 0000000..7d3f602 Binary files /dev/null and b/images/at-end.png differ diff --git a/images/beginning.png b/images/beginning.png new file mode 100644 index 0000000..6eda4f9 Binary files /dev/null and b/images/beginning.png differ diff --git a/images/in-midddle.png b/images/in-midddle.png new file mode 100644 index 0000000..66b428f Binary files /dev/null and b/images/in-midddle.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..ce87e78 --- /dev/null +++ b/main.py @@ -0,0 +1,124 @@ +""" +Issue 20, 15: Main entry point — wires everything together and outputs results. +""" + +import json +import logging +import os +import sys +import time +from pathlib import Path + +# Ensure project root is on sys.path +ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT)) + +from src.agent_registry import AgentRegistry +from src.data_loader import load_agents, load_constraints, load_environment_edges, load_orders +from src.dispatcher import Dispatcher +from src.graph import EnvironmentGraph +from src.metrics import MetricsCollector +from src.order_queue import PriorityOrderQueue + +# ── Logging setup ───────────────────────────────────────────────────────────── + +def _setup_logging(level: str = "INFO") -> None: + fmt = "%(asctime)s %(levelname)-8s %(name)s — %(message)s" + logging.basicConfig(level=getattr(logging, level.upper(), logging.INFO), + format=fmt, datefmt="%H:%M:%S") + + +# ── Path configuration ──────────────────────────────────────────────────────── + +DATA_DIR = ROOT / "data" / "raw" +OUTPUT_DIR = ROOT / "output" + +ORDERS_CSV = DATA_DIR / "orders.csv" +AGENTS_CSV = DATA_DIR / "agents.csv" +ENV_EDGES_CSV = DATA_DIR / "environment_edges.csv" +CONSTRAINTS_CSV = DATA_DIR / "constraints.csv" + +METRICS_JSON = OUTPUT_DIR / "metrics.json" +ASSIGNMENTS_LOG = OUTPUT_DIR / "assignments.log" + + +def main(): + _setup_logging("INFO") + logger = logging.getLogger("main") + + t_start = time.perf_counter() + + # ── 1. Load data ───────────────────────────────────────────────────────── + logger.info("Loading data from %s …", DATA_DIR) + + try: + orders = load_orders(str(ORDERS_CSV)) + agents = load_agents(str(AGENTS_CSV)) + edges = load_environment_edges(str(ENV_EDGES_CSV)) + constraints = load_constraints(str(CONSTRAINTS_CSV)) + except (FileNotFoundError, ValueError) as exc: + logger.critical("Data loading failed: %s", exc) + sys.exit(1) + + if not orders: + logger.critical("No valid orders to process, exiting.") + sys.exit(1) + if not agents: + logger.critical("No valid agents, exiting.") + sys.exit(1) + + # ── 2. Build graph ─────────────────────────────────────────────────────── + logger.info("Building environment graph …") + graph = EnvironmentGraph(edges) + + # Validate agent locations against graph (Issue 2) + for agent in agents: + if not graph.validate_location(agent.current_location): + logger.warning("Agent %s location %s not in graph — may be isolated", + agent.agent_id, agent.current_location) + + # ── 3. Initialise components ───────────────────────────────────────────── + queue = PriorityOrderQueue() + registry = AgentRegistry(agents, max_active=constraints.max_active_orders_per_agent) + metrics = MetricsCollector() + metrics.latency_target_ms = constraints.decision_latency_target_seconds * 1000 + + # Objective weights (Issue 8) + weights = { + "delivery_time": constraints.weight_delivery_time, + "sla_risk": constraints.weight_sla_risk, + "workload": constraints.weight_workload, + "priority": constraints.weight_priority, + "rating": constraints.weight_rating, + } + + # ── 4. Run simulation ──────────────────────────────────────────────────── + logger.info("Starting dispatch simulation for %d orders …", len(orders)) + dispatcher = Dispatcher( + queue=queue, + registry=registry, + graph=graph, + constraints=constraints, + metrics=metrics, + scoring_weights=weights, + ) + dispatcher.run(orders, tick_minutes=1.0) + + # ── 5. Output results ───────────────────────────────────────────────────── + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + # JSON metrics (Issue 15) + metrics_dict = metrics.to_dict(dataset_name="orders") + with open(METRICS_JSON, "w") as f: + json.dump(metrics_dict, f, indent=2) + logger.info("Metrics written to %s", METRICS_JSON) + + # Human-readable summary + print("\n" + metrics.summary()) + + elapsed = time.perf_counter() - t_start + logger.info("Total wall-clock time: %.2f s", elapsed) + + +if __name__ == "__main__": + main() diff --git a/output/metrics.json b/output/metrics.json new file mode 100644 index 0000000..6f5ebe0 --- /dev/null +++ b/output/metrics.json @@ -0,0 +1,62 @@ +{ + "metadata": { + "timestamp": "2026-05-06T07:19:51.246314+00:00", + "dataset": "orders", + "total_orders_delivered": 150, + "total_sla_violations": 0 + }, + "delivery_time": { + "overall": { + "count": 150, + "mean": 13.74, + "std": 4.153 + }, + "by_priority": { + "high": { + "count": 51, + "mean": 13.49, + "std": 4.96 + }, + "normal": { + "count": 50, + "mean": 15.26, + "std": 3.665 + }, + "low": { + "count": 49, + "mean": 12.449, + "std": 3.064 + } + } + }, + "sla_compliance": { + "compliance_rate": 1.0, + "violation_rate": 0.0, + "avg_sla_margin_minutes": 37.767, + "violations_by_priority": { + "high": 0, + "normal": 0, + "low": 0 + }, + "delivered_by_priority": { + "high": 51, + "normal": 50, + "low": 49 + } + }, + "workload_fairness": { + "load_variance": 0.16, + "load_std": 0.4, + "min_assignments": 5, + "max_assignments": 7, + "assignment_range": 2, + "mean_assignments": 6.0 + }, + "decision_latency": { + "count": 150, + "mean_ms": 0.05, + "max_ms": 0.27, + "min_ms": 0.03, + "violations": 0 + } +} \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..6d8acd5 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +# Smart Delivery Dispatch System diff --git a/src/__pycache__/__init__.cpython-313.pyc b/src/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..74ae9c7 Binary files /dev/null and b/src/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/__pycache__/agent_registry.cpython-313.pyc b/src/__pycache__/agent_registry.cpython-313.pyc new file mode 100644 index 0000000..12b670d Binary files /dev/null and b/src/__pycache__/agent_registry.cpython-313.pyc differ diff --git a/src/__pycache__/data_loader.cpython-313.pyc b/src/__pycache__/data_loader.cpython-313.pyc new file mode 100644 index 0000000..614a763 Binary files /dev/null and b/src/__pycache__/data_loader.cpython-313.pyc differ diff --git a/src/__pycache__/dispatcher.cpython-313.pyc b/src/__pycache__/dispatcher.cpython-313.pyc new file mode 100644 index 0000000..da3803a Binary files /dev/null and b/src/__pycache__/dispatcher.cpython-313.pyc differ diff --git a/src/__pycache__/graph.cpython-313.pyc b/src/__pycache__/graph.cpython-313.pyc new file mode 100644 index 0000000..f2865af Binary files /dev/null and b/src/__pycache__/graph.cpython-313.pyc differ diff --git a/src/__pycache__/metrics.cpython-313.pyc b/src/__pycache__/metrics.cpython-313.pyc new file mode 100644 index 0000000..8969183 Binary files /dev/null and b/src/__pycache__/metrics.cpython-313.pyc differ diff --git a/src/__pycache__/order_queue.cpython-313.pyc b/src/__pycache__/order_queue.cpython-313.pyc new file mode 100644 index 0000000..656127a Binary files /dev/null and b/src/__pycache__/order_queue.cpython-313.pyc differ diff --git a/src/__pycache__/scorer.cpython-313.pyc b/src/__pycache__/scorer.cpython-313.pyc new file mode 100644 index 0000000..74ae9e2 Binary files /dev/null and b/src/__pycache__/scorer.cpython-313.pyc differ diff --git a/src/agent_registry.py b/src/agent_registry.py new file mode 100644 index 0000000..83b7893 --- /dev/null +++ b/src/agent_registry.py @@ -0,0 +1,72 @@ +""" +Issue 5: Agent Registry — maintains agent state and provides fast lookups. +""" + +import logging +from typing import Dict, List, Optional + +from src.data_loader import Agent + +logger = logging.getLogger(__name__) + + +class AgentRegistry: + def __init__(self, agents: List[Agent], max_active: int = 2): + self._agents: Dict[str, Agent] = {a.agent_id: a for a in agents} + self._max_active = max_active + logger.info("Agent registry initialized with %d agents", len(agents)) + + # ── Lookups ─────────────────────────────────────────────────────────────── + + def get(self, agent_id: str) -> Optional[Agent]: + return self._agents.get(agent_id) + + def all_agents(self) -> List[Agent]: + return list(self._agents.values()) + + def available_agents(self) -> List[Agent]: + """Issue 5: agents with active_orders < max_active.""" + return [a for a in self._agents.values() if len(a.active_orders) < self._max_active] + + # ── Mutations ───────────────────────────────────────────────────────────── + + def assign_order(self, agent_id: str, order_id: str) -> bool: + agent = self._agents.get(agent_id) + if agent is None: + logger.error("assign_order: agent %s not found", agent_id) + return False + if len(agent.active_orders) >= self._max_active: + logger.warning("Agent %s at max capacity (%d)", agent_id, self._max_active) + return False + agent.active_orders.append(order_id) + agent.cumulative_assignments += 1 + logger.debug("Agent %s assigned order %s (active=%d)", agent_id, order_id, len(agent.active_orders)) + return True + + def complete_order(self, agent_id: str, order_id: str, new_location=None) -> bool: + """Issue 10: Remove order from agent, optionally update location.""" + agent = self._agents.get(agent_id) + if agent is None: + logger.error("complete_order: agent %s not found", agent_id) + return False + if order_id in agent.active_orders: + agent.active_orders.remove(order_id) + else: + logger.warning("complete_order: order %s not in agent %s active_orders", order_id, agent_id) + if new_location is not None: + agent.current_x, agent.current_y = new_location + logger.debug("Agent %s completed order %s, now at %s (active=%d)", + agent_id, order_id, agent.current_location, len(agent.active_orders)) + return True + + def snapshot(self) -> Dict: + return { + aid: { + "location": a.current_location, + "rating": a.rating, + "active_orders": list(a.active_orders), + "cumulative_assignments": a.cumulative_assignments, + "available": a.available, + } + for aid, a in self._agents.items() + } diff --git a/src/data_loader.py b/src/data_loader.py new file mode 100644 index 0000000..fe1b6a7 --- /dev/null +++ b/src/data_loader.py @@ -0,0 +1,290 @@ +""" +Issue 1, 2, 3, 16: Load and validate CSV data for orders, agents, environment. +""" + +import csv +import logging +import os +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +# ─── Data Models ──────────────────────────────────────────────────────────── + +@dataclass +class Order: + order_id: str + timestamp: datetime + location_x: int + location_y: int + prep_time_minutes: float + priority: str # "high" | "normal" | "low" + sla_minutes: float + state: str = "PENDING" # PENDING → ASSIGNED → IN_TRANSIT → DELIVERED + assigned_agent_id: Optional[str] = None + assigned_at: Optional[datetime] = None + delivered_at: Optional[datetime] = None + sla_violated: bool = False + + @property + def location(self) -> Tuple[int, int]: + return (self.location_x, self.location_y) + + +@dataclass +class Agent: + agent_id: str + current_x: int + current_y: int + rating: float + active_orders: List[str] = field(default_factory=list) + cumulative_assignments: int = 0 + + @property + def available(self) -> bool: + return len(self.active_orders) < 2 + + @property + def current_location(self) -> Tuple[int, int]: + return (self.current_x, self.current_y) + + +@dataclass +class Constraints: + 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 + # Objective Weights (Issue 8) + weight_delivery_time: float = 0.35 + weight_sla_risk: float = 0.30 + weight_workload: float = 0.20 + weight_priority: float = 0.10 + weight_rating: float = 0.05 + + +# ─── Validators ───────────────────────────────────────────────────────────── + +VALID_PRIORITIES = {"high", "normal", "low"} +VALID_STATES = {"PENDING", "ASSIGNED", "IN_TRANSIT", "DELIVERED"} + + +def _parse_int(value: str, field_name: str, record_id: str) -> Optional[int]: + try: + return int(value) + except (ValueError, TypeError): + logger.error("Record %s: field '%s' has invalid integer value '%s'", record_id, field_name, value) + return None + + +def _parse_float(value: str, field_name: str, record_id: str) -> Optional[float]: + try: + return float(value) + except (ValueError, TypeError): + logger.error("Record %s: field '%s' has invalid float value '%s'", record_id, field_name, value) + return None + + +def _parse_datetime(value: str, record_id: str) -> Optional[datetime]: + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"): + try: + return datetime.strptime(value.strip(), fmt) + except ValueError: + continue + logger.error("Record %s: timestamp '%s' could not be parsed", record_id, value) + return None + + +# ─── Loaders ───────────────────────────────────────────────────────────────── + +def load_orders(filepath: str) -> List[Order]: + """Issue 1: Load and validate orders CSV.""" + if not os.path.isfile(filepath): + raise FileNotFoundError(f"Orders CSV not found: {filepath}") + + required_fields = {"order_id", "timestamp", "location_x", "location_y", + "prep_time_minutes", "priority", "sla_minutes"} + orders: List[Order] = [] + skipped = 0 + + with open(filepath, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + if not required_fields.issubset(set(reader.fieldnames or [])): + missing = required_fields - set(reader.fieldnames or []) + raise ValueError(f"Orders CSV missing required columns: {missing}") + + for row in reader: + oid = row.get("order_id", "UNKNOWN").strip() + if not oid: + logger.warning("Skipping row with empty order_id") + skipped += 1 + continue + + ts = _parse_datetime(row.get("timestamp", ""), oid) + lx = _parse_int(row.get("location_x", ""), "location_x", oid) + ly = _parse_int(row.get("location_y", ""), "location_y", oid) + pt = _parse_float(row.get("prep_time_minutes", ""), "prep_time_minutes", oid) + pr = row.get("priority", "").strip().lower() + sl = _parse_float(row.get("sla_minutes", ""), "sla_minutes", oid) + + if any(v is None for v in [ts, lx, ly, pt, sl]): + logger.error("Skipping order %s due to invalid numeric fields", oid) + skipped += 1 + continue + if pr not in VALID_PRIORITIES: + logger.error("Skipping order %s: invalid priority '%s'", oid, pr) + skipped += 1 + continue + if pt < 0: + logger.warning("Order %s: negative prep_time_minutes (%s), clamping to 0", oid, pt) + pt = 0.0 + if sl <= 0: + logger.error("Skipping order %s: sla_minutes must be positive (%s)", oid, sl) + skipped += 1 + continue + + orders.append(Order( + order_id=oid, + timestamp=ts, + location_x=lx, + location_y=ly, + prep_time_minutes=pt, + priority=pr, + sla_minutes=sl, + )) + + logger.info("Loaded %d orders (%d skipped)", len(orders), skipped) + return orders + + +def load_agents(filepath: str) -> List[Agent]: + """Issue 2: Load and validate agents CSV.""" + if not os.path.isfile(filepath): + raise FileNotFoundError(f"Agents CSV not found: {filepath}") + + required_fields = {"agent_id", "current_x", "current_y", "rating"} + agents: List[Agent] = [] + skipped = 0 + + with open(filepath, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + if not required_fields.issubset(set(reader.fieldnames or [])): + missing = required_fields - set(reader.fieldnames or []) + raise ValueError(f"Agents CSV missing required columns: {missing}") + + for row in reader: + aid = row.get("agent_id", "UNKNOWN").strip() + if not aid: + logger.warning("Skipping row with empty agent_id") + skipped += 1 + continue + + cx = _parse_int(row.get("current_x", ""), "current_x", aid) + cy = _parse_int(row.get("current_y", ""), "current_y", aid) + rt = _parse_float(row.get("rating", ""), "rating", aid) + + if any(v is None for v in [cx, cy, rt]): + logger.error("Skipping agent %s due to invalid fields", aid) + skipped += 1 + continue + if not (0.0 <= rt <= 5.0): + logger.warning("Agent %s: rating %.2f out of [0,5] range, clamping", aid, rt) + rt = max(0.0, min(5.0, rt)) + + agents.append(Agent( + agent_id=aid, + current_x=cx, + current_y=cy, + rating=rt, + )) + + logger.info("Loaded %d agents (%d skipped)", len(agents), skipped) + return agents + + +def load_environment_edges(filepath: str) -> List[dict]: + """Issue 3: Load environment graph edges CSV.""" + if not os.path.isfile(filepath): + raise FileNotFoundError(f"Environment edges CSV not found: {filepath}") + + required_fields = {"from_x", "from_y", "to_x", "to_y", "distance_minutes", "delay_multiplier"} + edges: List[dict] = [] + skipped = 0 + + with open(filepath, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + if not required_fields.issubset(set(reader.fieldnames or [])): + missing = required_fields - set(reader.fieldnames or []) + raise ValueError(f"Environment CSV missing required columns: {missing}") + + for i, row in enumerate(reader, start=2): + fx = _parse_int(row.get("from_x", ""), "from_x", f"row{i}") + fy = _parse_int(row.get("from_y", ""), "from_y", f"row{i}") + tx = _parse_int(row.get("to_x", ""), "to_x", f"row{i}") + ty = _parse_int(row.get("to_y", ""), "to_y", f"row{i}") + dist = _parse_float(row.get("distance_minutes", ""), "distance_minutes", f"row{i}") + delay = _parse_float(row.get("delay_multiplier", ""), "delay_multiplier", f"row{i}") + + if any(v is None for v in [fx, fy, tx, ty, dist, delay]): + skipped += 1 + continue + if dist < 0: + logger.warning("Row %d: negative distance, skipping", i) + skipped += 1 + continue + + edges.append({ + "from": (fx, fy), "to": (tx, ty), + "distance_minutes": dist, "delay_multiplier": delay, + }) + + logger.info("Loaded %d environment edges (%d skipped)", len(edges), skipped) + return edges + + +def load_constraints(filepath: str) -> Constraints: + """Load system constraints from CSV.""" + if not os.path.isfile(filepath): + logger.warning("Constraints CSV not found at %s, using defaults", filepath) + return Constraints() + + c = Constraints() + with open(filepath, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + for row in reader: + key = row.get("constraint", "").strip() + val = row.get("value", "").strip() + if not key or not val: + continue + try: + if key == "max_active_orders_per_agent": + c.max_active_orders_per_agent = int(val) + elif key == "decision_latency_target_seconds": + c.decision_latency_target_seconds = float(val) + elif key == "default_sla_minutes": + c.default_sla_minutes = float(val) + elif key == "priority_weight_high": + c.priority_weight_high = float(val) + elif key == "priority_weight_normal": + c.priority_weight_normal = float(val) + elif key == "priority_weight_low": + c.priority_weight_low = float(val) + elif key == "weight_delivery_time": + c.weight_delivery_time = float(val) + elif key == "weight_sla_risk": + c.weight_sla_risk = float(val) + elif key == "weight_workload": + c.weight_workload = float(val) + elif key == "weight_priority": + c.weight_priority = float(val) + elif key == "weight_rating": + c.weight_rating = float(val) + except ValueError: + logger.warning("Constraint '%s': invalid value '%s', using default", key, val) + + logger.info("Loaded constraints: %s", c) + return c diff --git a/src/dispatcher.py b/src/dispatcher.py new file mode 100644 index 0000000..c78fec8 --- /dev/null +++ b/src/dispatcher.py @@ -0,0 +1,208 @@ +""" +Issues 9, 10, 11, 17, 18, 19: Core dispatcher — orchestrates the full +assignment + delivery simulation pipeline. +""" + +import logging +import time +from datetime import datetime, timedelta +from typing import List, Optional + +from src.agent_registry import AgentRegistry +from src.data_loader import Constraints, Order +from src.graph import EnvironmentGraph +from src.metrics import MetricsCollector +from src.order_queue import PriorityOrderQueue +from src.scorer import select_best_candidate + +logger = logging.getLogger(__name__) + + +class Dispatcher: + """ + Simulation clock-driven dispatcher. + + Each 'tick' advances the simulation by `tick_minutes`. On each tick: + 1. Accept newly arrived orders (by timestamp). + 2. Try to assign pending orders to available agents. + 3. Advance in-flight deliveries toward completion. + 4. Record metrics. + """ + + def __init__( + self, + queue: PriorityOrderQueue, + registry: AgentRegistry, + graph: EnvironmentGraph, + constraints: Constraints, + metrics: MetricsCollector, + scoring_weights: Optional[dict] = None, + ): + self._queue = queue + self._registry = registry + self._graph = graph + self._constraints = constraints + self._metrics = metrics + self._weights = scoring_weights + self._in_flight: List[dict] = [] # [{order_id, agent_id, remaining_minutes}] + self._sim_time: Optional[datetime] = None + self._elapsed_minutes: float = 0.0 + self._orders_per_minute: float = 0.0 + self._tick_count: int = 0 + + # ── Main simulation entry point ───────────────────────────────────────── + + def run(self, all_orders: List[Order], tick_minutes: float = 1.0) -> None: + """ + Issue 19: Process all orders in chronological simulation. + tick_minutes: simulated time per step. + """ + if not all_orders: + logger.warning("No orders to simulate") + return + + wall_start = time.perf_counter() + + # Initialise simulation clock to first order timestamp + sorted_orders = sorted(all_orders, key=lambda o: o.timestamp) + self._sim_time = sorted_orders[0].timestamp + pending_arrivals = list(sorted_orders) + + total_orders = len(all_orders) + logger.info("Simulation start: %s | %d orders | tick=%.1f min", + self._sim_time.isoformat(), total_orders, tick_minutes) + + while pending_arrivals or self._queue.pending_count() > 0 or self._in_flight: + # 1. Ingest arrivals up to current sim_time + while pending_arrivals and pending_arrivals[0].timestamp <= self._sim_time: + self._queue.push(pending_arrivals.pop(0)) + + # 2. Attempt assignments for all pending orders (Issue 11) + self._attempt_assignments() + + # 3. Advance in-flight deliveries + self._advance_deliveries(tick_minutes) + + # 4. Tick forward + self._sim_time += timedelta(minutes=tick_minutes) + self._elapsed_minutes += tick_minutes + self._tick_count += 1 + + # Issue 19: throughput is a processing-performance metric, so track it + # against wall-clock runtime rather than simulated business time. + wall_elapsed_seconds = max(time.perf_counter() - wall_start, 1e-9) + throughput_per_minute = (total_orders / wall_elapsed_seconds) * 60 + logger.info( + "Simulation complete: %.1f simulated min elapsed, %.1f orders/min processing throughput", + self._elapsed_minutes, + throughput_per_minute, + ) + if throughput_per_minute < 100: + logger.warning( + "Throughput %.1f orders/min below target 100/min (Issue 19)", + throughput_per_minute, + ) + + # ── Assignment ──────────────────────────────────────────────────────────── + + def _attempt_assignments(self) -> None: + """Issue 9, 11: Assign highest-priority pending orders to available agents.""" + while True: + order = self._queue.peek_pending() + if order is None: + break + if not self._registry.available_agents(): + logger.debug("No available agents; %d orders remain pending", self._queue.pending_count()) + break # Issue 11: keep in queue + + # Check if SLA already violated before assignment (Issue 17) + time_since_order = (self._sim_time - order.timestamp).total_seconds() / 60.0 + if time_since_order > order.sla_minutes: + logger.warning("Order %s: SLA deadline already passed (%.1f min late), assigning anyway", + order.order_id, time_since_order - order.sla_minutes) + + t0 = time.perf_counter() + best = select_best_candidate( + order, self._registry, self._graph, self._constraints, + weights=self._weights, + simulation_elapsed_minutes=self._elapsed_minutes, + ) + latency_ms = (time.perf_counter() - t0) * 1000 + self._metrics.record_latency_ms(latency_ms) # Issue 18 + + if best is None: + # No valid path exists for this order (Issue 17: empty candidate list) + logger.warning("Order %s: no feasible agent found, will retry next tick", order.order_id) + break + + # Issue 9: Atomic state update + popped = self._queue.pop_pending() + if popped is None or popped.order_id != order.order_id: + logger.error("Queue race condition for order %s", order.order_id) + break + + ok_q = self._queue.mark_assigned(order.order_id, best.agent.agent_id, self._sim_time) + ok_a = self._registry.assign_order(best.agent.agent_id, order.order_id) + + if not (ok_q and ok_a): + logger.error("Assignment failed atomically for order %s / agent %s", + order.order_id, best.agent.agent_id) + continue + + self._metrics.record_assignment(best.agent.agent_id) + + # Schedule delivery completion + self._in_flight.append({ + "order_id": order.order_id, + "agent_id": best.agent.agent_id, + "remaining_minutes": best.estimated_delivery, + "delivery_location": order.location, + "sla_minutes": order.sla_minutes, + "priority": order.priority, + "order_timestamp": order.timestamp, + }) + self._queue.mark_in_transit(order.order_id) + + logger.info("✓ Order %-5s → Agent %-5s travel=%.1fm delivery=%.1fm priority=%s", + order.order_id, best.agent.agent_id, + best.travel_time, best.estimated_delivery, order.priority) + + # ── Delivery simulation ─────────────────────────────────────────────────── + + def _advance_deliveries(self, tick_minutes: float) -> None: + """Issue 10: Decrement remaining time; deliver completed orders.""" + still_flying = [] + for flight in self._in_flight: + flight["remaining_minutes"] -= tick_minutes + if flight["remaining_minutes"] <= 0: + self._complete_delivery(flight) + else: + still_flying.append(flight) + self._in_flight = still_flying + + def _complete_delivery(self, flight: dict) -> None: + """Issue 10, 13: Finalise delivery and check SLA.""" + oid = flight["order_id"] + aid = flight["agent_id"] + + # Actual delivery time in minutes from order timestamp + actual_elapsed = (self._sim_time - flight["order_timestamp"]).total_seconds() / 60.0 + sla_violated = actual_elapsed > flight["sla_minutes"] + + self._queue.mark_delivered(oid, self._sim_time, sla_violated) + self._registry.complete_order(aid, oid, new_location=flight["delivery_location"]) + + self._metrics.record_delivery( + order_id=oid, + priority=flight["priority"], + delivery_time_minutes=actual_elapsed, + sla_minutes=flight["sla_minutes"], + sla_violated=sla_violated, + ) + + status = "⚠ SLA VIOLATED" if sla_violated else "✓ on time" + logger.info("Delivered %-5s by Agent %-5s %.1f min [%s]", + oid, aid, actual_elapsed, status) + + # Issue 11: After delivery, try to assign queued orders + self._attempt_assignments() diff --git a/src/graph.py b/src/graph.py new file mode 100644 index 0000000..1d1e643 --- /dev/null +++ b/src/graph.py @@ -0,0 +1,128 @@ +""" +Issue 3, 17: Build environment graph, compute shortest paths (Floyd-Warshall for small +graphs, Dijkstra for larger ones), handle disconnected locations. +""" + +import heapq +import logging +import math +from typing import Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +Location = Tuple[int, int] +INF = float("inf") + +# Threshold: use Floyd-Warshall below this node count, Dijkstra above +FLOYD_WARSHALL_THRESHOLD = 200 + + +class EnvironmentGraph: + """Undirected weighted graph over (x, y) grid locations.""" + + def __init__(self, edges: List[dict]): + self._adj: Dict[Location, List[Tuple[Location, float]]] = {} + self._nodes: set = set() + self._dist_cache: Dict[Tuple[Location, Location], float] = {} + self._precomputed: bool = False + + for e in edges: + src: Location = e["from"] + dst: Location = e["to"] + weight: float = e["distance_minutes"] * e["delay_multiplier"] + + self._nodes.add(src) + self._nodes.add(dst) + self._adj.setdefault(src, []).append((dst, weight)) + # Bidirectional graph + self._adj.setdefault(dst, []).append((src, weight)) + + logger.info("Graph built: %d nodes, %d directed edges", + len(self._nodes), sum(len(v) for v in self._adj.values())) + + if len(self._nodes) <= FLOYD_WARSHALL_THRESHOLD: + self._precompute_floyd_warshall() + else: + logger.info("Graph too large for Floyd-Warshall; will use Dijkstra on demand") + + # ── Shortest path precomputation ───────────────────────────────────────── + + def _precompute_floyd_warshall(self) -> None: + """Issue 3: Floyd-Warshall for small graphs; O(V³).""" + nodes = sorted(self._nodes) + n = len(nodes) + idx = {node: i for i, node in enumerate(nodes)} + + # Initialize distance matrix + dist = [[INF] * n for _ in range(n)] + for i in range(n): + dist[i][i] = 0.0 + for src, neighbours in self._adj.items(): + for dst, w in neighbours: + i, j = idx[src], idx[dst] + dist[i][j] = min(dist[i][j], w) + + # Relax + for k in range(n): + for i in range(n): + if dist[i][k] == INF: + continue + for j in range(n): + if dist[i][k] + dist[k][j] < dist[i][j]: + dist[i][j] = dist[i][k] + dist[k][j] + + # Store in cache + for i, src in enumerate(nodes): + for j, dst in enumerate(nodes): + self._dist_cache[(src, dst)] = dist[i][j] + + self._precomputed = True + logger.info("Floyd-Warshall precomputed for %d nodes", n) + + def _dijkstra(self, src: Location, dst: Location) -> float: + """Dijkstra's algorithm for on-demand queries (large graphs). Issue 17: handles disconnected.""" + if src not in self._nodes: + return INF + dist: Dict[Location, float] = {src: 0.0} + heap = [(0.0, src)] + while heap: + d, u = heapq.heappop(heap) + if u == dst: + return d + if d > dist.get(u, INF): + continue + for v, w in self._adj.get(u, []): + nd = d + w + if nd < dist.get(v, INF): + dist[v] = nd + heapq.heappush(heap, (nd, v)) + return INF # disconnected + + # ── Public API ──────────────────────────────────────────────────────────── + + def travel_time(self, src: Location, dst: Location) -> float: + """Return travel time in minutes between two (x, y) locations. + Returns float('inf') if no path exists (Issue 17). + """ + if src == dst: + return 0.0 + + # Check in-memory cache (populated by Floyd-Warshall or previous Dijkstra) + key = (src, dst) + if key in self._dist_cache: + return self._dist_cache[key] + + # Lazy Dijkstra for large graphs + t = self._dijkstra(src, dst) + self._dist_cache[key] = t + return t + + def has_path(self, src: Location, dst: Location) -> bool: + return self.travel_time(src, dst) < INF + + @property + def nodes(self) -> set: + return self._nodes + + def validate_location(self, loc: Location) -> bool: + return loc in self._nodes diff --git a/src/metrics.py b/src/metrics.py new file mode 100644 index 0000000..70d4b63 --- /dev/null +++ b/src/metrics.py @@ -0,0 +1,213 @@ +""" +Issues 12, 13, 14, 15: Metrics collection and structured output. +Uses Welford's algorithm for online mean/variance (Issue 12). +""" + +import json +import logging +import math +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + + +# ─── Welford running statistics ────────────────────────────────────────────── + +class RunningStats: + """Online mean and variance via Welford's algorithm (Issue 12).""" + + def __init__(self): + self.n: int = 0 + self._mean: float = 0.0 + self._M2: float = 0.0 + + def update(self, value: float) -> None: + self.n += 1 + delta = value - self._mean + self._mean += delta / self.n + delta2 = value - self._mean + self._M2 += delta * delta2 + + @property + def mean(self) -> float: + return self._mean if self.n > 0 else 0.0 + + @property + def variance(self) -> float: + return self._M2 / self.n if self.n > 0 else 0.0 + + @property + def std(self) -> float: + return math.sqrt(self.variance) + + def to_dict(self) -> dict: + return {"count": self.n, "mean": round(self.mean, 3), "std": round(self.std, 3)} + + +# ─── Metric accumulators ───────────────────────────────────────────────────── + +@dataclass +class MetricsCollector: + # Delivery time (Issue 12) + delivery_time_overall: RunningStats = field(default_factory=RunningStats) + delivery_time_by_priority: Dict[str, RunningStats] = field( + default_factory=lambda: {"high": RunningStats(), "normal": RunningStats(), "low": RunningStats()} + ) + + # SLA compliance (Issue 13) + total_delivered: int = 0 + sla_violations: int = 0 + sla_margin_sum: float = 0.0 # sum of (deadline - actual) + violations_by_priority: Dict[str, int] = field( + default_factory=lambda: {"high": 0, "normal": 0, "low": 0} + ) + delivered_by_priority: Dict[str, int] = field( + default_factory=lambda: {"high": 0, "normal": 0, "low": 0} + ) + + # Workload fairness (Issue 14) + agent_assignments: Dict[str, int] = field(default_factory=dict) + + # Latency tracking (Issue 18) + decision_latencies_ms: List[float] = field(default_factory=list) + latency_target_ms: float = 5000.0 + + def record_delivery( + self, + order_id: str, + priority: str, + delivery_time_minutes: float, + sla_minutes: float, + sla_violated: bool, + ) -> None: + """Record a completed delivery for all metric categories.""" + self.total_delivered += 1 + self.delivery_time_overall.update(delivery_time_minutes) + self.delivery_time_by_priority[priority].update(delivery_time_minutes) + + self.delivered_by_priority[priority] += 1 + margin = sla_minutes - delivery_time_minutes + self.sla_margin_sum += margin + if sla_violated: + self.sla_violations += 1 + self.violations_by_priority[priority] += 1 + + def record_assignment(self, agent_id: str) -> None: + self.agent_assignments[agent_id] = self.agent_assignments.get(agent_id, 0) + 1 + + def record_latency_ms(self, latency_ms: float) -> None: + self.decision_latencies_ms.append(latency_ms) + if latency_ms > self.latency_target_ms: + logger.warning("Decision latency %.1fms exceeds target %.1fms", + latency_ms, self.latency_target_ms) + + # ── Derived metrics ─────────────────────────────────────────────────────── + + @property + def sla_compliance_rate(self) -> float: + if self.total_delivered == 0: + return 1.0 + return round(1.0 - self.sla_violations / self.total_delivered, 4) + + @property + def sla_violation_rate(self) -> float: + if self.total_delivered == 0: + return 0.0 + return round(self.sla_violations / self.total_delivered, 4) + + @property + def avg_sla_margin(self) -> float: + if self.total_delivered == 0: + return 0.0 + return round(self.sla_margin_sum / self.total_delivered, 3) + + def _fairness_metrics(self) -> dict: + """Issue 14: load variance, std-dev, min/max.""" + if not self.agent_assignments: + return {"load_variance": 0.0, "load_std": 0.0, "min_assignments": 0, + "max_assignments": 0, "assignment_range": 0} + vals = list(self.agent_assignments.values()) + mn = min(vals) + mx = max(vals) + mean = sum(vals) / len(vals) + variance = sum((v - mean) ** 2 for v in vals) / len(vals) + return { + "load_variance": round(variance, 3), + "load_std": round(math.sqrt(variance), 3), + "min_assignments": mn, + "max_assignments": mx, + "assignment_range": mx - mn, + "mean_assignments": round(mean, 3), + } + + def _latency_metrics(self) -> dict: + if not self.decision_latencies_ms: + return {} + ls = self.decision_latencies_ms + return { + "count": len(ls), + "mean_ms": round(sum(ls) / len(ls), 2), + "max_ms": round(max(ls), 2), + "min_ms": round(min(ls), 2), + "violations": sum(1 for l in ls if l > self.latency_target_ms), + } + + # ── Issue 15: Structured JSON export ───────────────────────────────────── + + def to_dict(self, dataset_name: str = "orders") -> dict: + return { + "metadata": { + "timestamp": datetime.now(timezone.utc).isoformat(), + "dataset": dataset_name, + "total_orders_delivered": self.total_delivered, + "total_sla_violations": self.sla_violations, + }, + "delivery_time": { + "overall": self.delivery_time_overall.to_dict(), + "by_priority": {p: s.to_dict() for p, s in self.delivery_time_by_priority.items()}, + }, + "sla_compliance": { + "compliance_rate": self.sla_compliance_rate, + "violation_rate": self.sla_violation_rate, + "avg_sla_margin_minutes": self.avg_sla_margin, + "violations_by_priority": self.violations_by_priority, + "delivered_by_priority": self.delivered_by_priority, + }, + "workload_fairness": self._fairness_metrics(), + "decision_latency": self._latency_metrics(), + } + + def to_json(self, dataset_name: str = "orders", indent: int = 2) -> str: + return json.dumps(self.to_dict(dataset_name), indent=indent) + + def summary(self) -> str: + """Issue 15: Human-readable summary.""" + lines = [ + "═" * 55, + " SMART DELIVERY DISPATCH — METRICS SUMMARY", + "═" * 55, + f" Orders delivered : {self.total_delivered}", + f" SLA compliance : {self.sla_compliance_rate * 100:.1f}%", + f" SLA violations : {self.sla_violations}", + f" Avg delivery time : {self.delivery_time_overall.mean:.1f} min (±{self.delivery_time_overall.std:.1f})", + f" Avg SLA margin : {self.avg_sla_margin:.1f} min", + "── By priority ──────────────────────────────────────", + ] + for p in ("high", "normal", "low"): + s = self.delivery_time_by_priority[p] + v = self.violations_by_priority[p] + d = self.delivered_by_priority[p] + lines.append( + f" {p:6s} delivered={d:3d} avg={s.mean:.1f}m violations={v}" + ) + fm = self._fairness_metrics() + lines += [ + "── Workload fairness ────────────────────────────────", + f" Assignments min={fm['min_assignments']} max={fm['max_assignments']}" + f" range={fm['assignment_range']} std={fm['load_std']:.2f}", + "═" * 55, + ] + return "\n".join(lines) diff --git a/src/order_queue.py b/src/order_queue.py new file mode 100644 index 0000000..a1851e7 --- /dev/null +++ b/src/order_queue.py @@ -0,0 +1,131 @@ +""" +Issue 4: Priority Order Queue — processes orders by priority (high > normal > low) +and timestamp (FIFO within the same priority level). +State machine: PENDING → ASSIGNED → IN_TRANSIT → DELIVERED +""" + +import heapq +import logging +from datetime import datetime +from typing import Dict, List, Optional + +from src.data_loader import Order + +logger = logging.getLogger(__name__) + +# Priority levels: lower integer = higher dispatch priority +PRIORITY_RANK = {"high": 0, "normal": 1, "low": 2} + + +class PriorityOrderQueue: + """Min-heap keyed by (priority_rank, timestamp) for strict ordering.""" + + def __init__(self): + self._heap: list = [] # (rank, timestamp, order_id) tuples + self._orders: Dict[str, Order] = {} # all orders by state + self._by_state: Dict[str, List[str]] = { + "PENDING": [], "ASSIGNED": [], "IN_TRANSIT": [], "DELIVERED": [] + } + + # ── Ingestion ──────────────────────────────────────────────────────────── + + def push(self, order: Order) -> None: + if order.order_id in self._orders: + logger.warning("Order %s already in queue, ignoring duplicate", order.order_id) + return + rank = PRIORITY_RANK.get(order.priority, 1) + heapq.heappush(self._heap, (rank, order.timestamp, order.order_id)) + self._orders[order.order_id] = order + self._by_state["PENDING"].append(order.order_id) + logger.debug("Queued order %s (priority=%s, ts=%s)", order.order_id, order.priority, order.timestamp) + + def push_many(self, orders: List[Order]) -> None: + for o in orders: + self.push(o) + + # ── Retrieval ──────────────────────────────────────────────────────────── + + def peek_pending(self) -> Optional[Order]: + """Return highest-priority pending order without removing from heap.""" + temp = [] + result = None + while self._heap: + item = heapq.heappop(self._heap) + rank, ts, oid = item + order = self._orders[oid] + if order.state == "PENDING": + result = order + heapq.heappush(self._heap, item) + break + temp.append(item) + for item in temp: + heapq.heappush(self._heap, item) + return result + + def pop_pending(self) -> Optional[Order]: + """Remove and return highest-priority pending order.""" + while self._heap: + rank, ts, oid = heapq.heappop(self._heap) + order = self._orders[oid] + if order.state == "PENDING": + return order + # Skip non-pending (stale heap entries) + return None + + def get_pending(self) -> List[Order]: + """Return all PENDING orders sorted by priority+timestamp.""" + pending = [self._orders[oid] for oid in self._by_state["PENDING"]] + return sorted(pending, key=lambda o: (PRIORITY_RANK[o.priority], o.timestamp)) + + def get_by_state(self, state: str) -> List[Order]: + return [self._orders[oid] for oid in self._by_state.get(state, [])] + + # ── State transitions ───────────────────────────────────────────────────── + + def _transition(self, order_id: str, new_state: str) -> bool: + if order_id not in self._orders: + logger.error("Order %s not found in queue", order_id) + return False + order = self._orders[order_id] + valid_transitions = { + "PENDING": "ASSIGNED", + "ASSIGNED": "IN_TRANSIT", + "IN_TRANSIT": "DELIVERED", + } + if valid_transitions.get(order.state) != new_state: + logger.error("Invalid transition %s → %s for order %s", order.state, new_state, order_id) + return False + self._by_state[order.state].remove(order_id) + order.state = new_state + self._by_state[new_state].append(order_id) + return True + + def mark_assigned(self, order_id: str, agent_id: str, at: datetime) -> bool: + if not self._transition(order_id, "ASSIGNED"): + return False + o = self._orders[order_id] + o.assigned_agent_id = agent_id + o.assigned_at = at + return True + + def mark_in_transit(self, order_id: str) -> bool: + return self._transition(order_id, "IN_TRANSIT") + + def mark_delivered(self, order_id: str, at: datetime, sla_violated: bool) -> bool: + if not self._transition(order_id, "DELIVERED"): + return False + o = self._orders[order_id] + o.delivered_at = at + o.sla_violated = sla_violated + return True + + # ── Helpers ─────────────────────────────────────────────────────────────── + + def get_order(self, order_id: str) -> Optional[Order]: + return self._orders.get(order_id) + + def pending_count(self) -> int: + return len(self._by_state["PENDING"]) + + def __len__(self) -> int: + return len(self._orders) diff --git a/src/scorer.py b/src/scorer.py new file mode 100644 index 0000000..e632feb --- /dev/null +++ b/src/scorer.py @@ -0,0 +1,156 @@ +""" +Issues 6, 7, 8: Candidate generation and assignment scoring. + +Scoring formula (lower = better): + score = w_time * normalised_delivery_time + - w_sla * sla_margin_ratio (negative → urgency bonus) + + w_fair * agent_load_ratio + - w_pri * priority_weight + - w_rating * normalised_rating +""" + +import logging +from dataclasses import dataclass +from typing import List, Optional, Tuple + +from src.agent_registry import AgentRegistry +from src.data_loader import Agent, Constraints, Order +from src.graph import EnvironmentGraph + +logger = logging.getLogger(__name__) + +# Scoring weight defaults (Issue 8: configurable) +DEFAULT_WEIGHTS = { + "delivery_time": 0.35, + "sla_risk": 0.30, + "workload": 0.20, + "priority": 0.10, + "rating": 0.05, +} + + +@dataclass +class Candidate: + agent: Agent + order: Order + travel_time: float # agent → order location (minutes) + estimated_delivery: float # travel + prep_time (minutes) + + @property + def score(self) -> float: + return self._score + + def set_score(self, score: float) -> None: + self._score = score + + +def generate_candidates( + order: Order, + registry: AgentRegistry, + graph: EnvironmentGraph, +) -> List[Candidate]: + """Issue 6: Find available agents that have a valid path to the order location.""" + candidates: List[Candidate] = [] + order_loc = order.location + + for agent in registry.available_agents(): + t = graph.travel_time(agent.current_location, order_loc) + if t == float("inf"): + logger.debug("Agent %s has no path to order %s, skipping", agent.agent_id, order.order_id) + continue + estimated = t + order.prep_time_minutes + c = Candidate(agent=agent, order=order, + travel_time=t, estimated_delivery=estimated) + c.set_score(float("inf")) # placeholder until scored + candidates.append(c) + + logger.debug("Order %s: %d feasible candidates", order.order_id, len(candidates)) + return candidates + + +def score_candidates( + candidates: List[Candidate], + constraints: Constraints, + registry: AgentRegistry, + weights: Optional[dict] = None, + simulation_elapsed_minutes: float = 0.0, +) -> List[Candidate]: + """Issue 7: Score and rank candidates. Lower score = better match.""" + if not candidates: + return [] + + w = {**DEFAULT_WEIGHTS, **(weights or {})} + + # Pre-compute normalisation bounds + max_delivery = max(c.estimated_delivery for c in candidates) or 1.0 + max_assignments = max( + a.cumulative_assignments for a in registry.all_agents() + ) or 1.0 + + priority_weights = { + "high": constraints.priority_weight_high, + "normal": constraints.priority_weight_normal, + "low": constraints.priority_weight_low, + } + + scored: List[Candidate] = [] + for c in candidates: + order = c.order + + # 1. Delivery time component (lower → better) + time_component = c.estimated_delivery / max_delivery + + # 2. SLA risk component: how close are we to missing the deadline? + sla_remaining = order.sla_minutes - (simulation_elapsed_minutes + c.estimated_delivery) + sla_ratio = sla_remaining / order.sla_minutes # positive = margin left + # Clamp to [-1, 1] so extreme cases don't dominate + sla_component = max(-1.0, min(1.0, sla_ratio)) + + # 3. Workload fairness (higher load → penalise) + load_ratio = c.agent.cumulative_assignments / max_assignments + workload_component = load_ratio + + # 4. Priority (higher priority → bonus) + priority_component = priority_weights.get(order.priority, 1.0) + + # 5. Agent rating (higher → bonus) + rating_component = c.agent.rating / 5.0 + + # Composite: minimise score + score = ( + w["delivery_time"] * time_component + - w["sla_risk"] * sla_component # bigger margin → lower score + + w["workload"] * workload_component + - w["priority"] * (priority_component / constraints.priority_weight_high) + - w["rating"] * rating_component + ) + c.set_score(score) + scored.append(c) + + # Sort ascending (best first); tiebreak by agent_id (Issue 17) + scored.sort(key=lambda c: (round(c.score, 8), c.agent.agent_id)) + return scored + + +def select_best_candidate( + order: Order, + registry: AgentRegistry, + graph: EnvironmentGraph, + constraints: Constraints, + weights: Optional[dict] = None, + simulation_elapsed_minutes: float = 0.0, +) -> Optional[Candidate]: + """Issues 6+7+17: Full pipeline — generate, score, return best.""" + candidates = generate_candidates(order, registry, graph) + if not candidates: + logger.info("Order %s: no available candidates (Issue 17 — will queue)", order.order_id) + return None + + scored = score_candidates(candidates, constraints, registry, + weights=weights, + simulation_elapsed_minutes=simulation_elapsed_minutes) + best = scored[0] + logger.debug("Order %s → Agent %s (score=%.4f, travel=%.1fm, delivery=%.1fm)", + order.order_id, best.agent.agent_id, best.score, + best.travel_time, best.estimated_delivery) + return best diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..65140f2 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# tests package diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..79256af Binary files /dev/null and b/tests/__pycache__/__init__.cpython-313.pyc differ diff --git a/tests/__pycache__/test_dispatch.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_dispatch.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000..32f5157 Binary files /dev/null and b/tests/__pycache__/test_dispatch.cpython-313-pytest-9.0.3.pyc differ diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py new file mode 100644 index 0000000..940e8d7 --- /dev/null +++ b/tests/test_dispatch.py @@ -0,0 +1,488 @@ +""" +Tests covering all 20 issues in ISSUES.md. +Run with: python3 -m pytest tests/ -v +""" + +import json +import math +import os +import sys +import tempfile +from datetime import datetime +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from src.agent_registry import AgentRegistry +from src.data_loader import ( + Agent, + Constraints, + Order, + load_agents, + load_constraints, + load_environment_edges, + load_orders, +) +from src.dispatcher import Dispatcher +from src.graph import EnvironmentGraph +from src.metrics import MetricsCollector, RunningStats +from src.order_queue import PriorityOrderQueue, PRIORITY_RANK +from src.scorer import generate_candidates, score_candidates, select_best_candidate + + +# ── Fixtures ────────────────────────────────────────────────────────────────── + +def make_order(oid="O001", priority="high", prep=10, sla=50, + lx=2, ly=3, ts_str="2026-05-03 09:00:00"): + return Order( + order_id=oid, + timestamp=datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S"), + location_x=lx, location_y=ly, + prep_time_minutes=prep, + priority=priority, + sla_minutes=sla, + ) + + +def make_agent(aid="A001", cx=0, cy=0, rating=4.5): + return Agent(agent_id=aid, current_x=cx, current_y=cy, rating=rating) + + +def make_grid_edges(size=5): + """Build simple grid graph edges for testing.""" + edges = [] + for x in range(size): + for y in range(size): + if x + 1 < size: + edges.append({"from": (x, y), "to": (x + 1, y), + "distance_minutes": 3, "delay_multiplier": 1.0}) + if y + 1 < size: + edges.append({"from": (x, y), "to": (x, y + 1), + "distance_minutes": 3, "delay_multiplier": 1.0}) + return edges + + +@pytest.fixture +def small_graph(): + return EnvironmentGraph(make_grid_edges(size=5)) + + +@pytest.fixture +def constraints(): + return Constraints(max_active_orders_per_agent=2) + + +@pytest.fixture +def real_graph(): + edges_path = ROOT / "data" / "raw" / "environment_edges.csv" + edges = load_environment_edges(str(edges_path)) + return EnvironmentGraph(edges) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Issue 1: Load and Validate Orders +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestLoadOrders: + def test_loads_real_orders(self): + path = ROOT / "data" / "raw" / "orders.csv" + orders = load_orders(str(path)) + assert len(orders) == 150 + for o in orders: + assert o.priority in {"high", "normal", "low"} + assert o.sla_minutes > 0 + assert o.prep_time_minutes >= 0 + + def test_missing_file_raises(self, tmp_path): + with pytest.raises(FileNotFoundError): + load_orders(str(tmp_path / "nonexistent.csv")) + + def test_invalid_priority_skipped(self, tmp_path): + csv = tmp_path / "orders.csv" + csv.write_text( + "order_id,timestamp,location_x,location_y,prep_time_minutes,priority,sla_minutes\n" + "O001,2026-05-03 09:00:00,1,1,10,INVALID,50\n" + "O002,2026-05-03 09:00:00,1,1,10,high,50\n" + ) + orders = load_orders(str(csv)) + assert len(orders) == 1 + assert orders[0].order_id == "O002" + + def test_negative_sla_skipped(self, tmp_path): + csv = tmp_path / "orders.csv" + csv.write_text( + "order_id,timestamp,location_x,location_y,prep_time_minutes,priority,sla_minutes\n" + "O001,2026-05-03 09:00:00,1,1,10,high,-5\n" + ) + orders = load_orders(str(csv)) + assert len(orders) == 0 + + def test_missing_columns_raises(self, tmp_path): + csv = tmp_path / "orders.csv" + csv.write_text("order_id,timestamp\nO001,2026-01-01 00:00:00\n") + with pytest.raises(ValueError): + load_orders(str(csv)) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Issue 2: Load and Validate Agents +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestLoadAgents: + def test_loads_real_agents(self): + path = ROOT / "data" / "raw" / "agents.csv" + agents = load_agents(str(path)) + assert len(agents) == 25 + for a in agents: + assert 0 <= a.rating <= 5 + assert a.active_orders == [] + assert a.cumulative_assignments == 0 + + def test_missing_file_raises(self, tmp_path): + with pytest.raises(FileNotFoundError): + load_agents(str(tmp_path / "missing.csv")) + + def test_bad_rating_clamped(self, tmp_path): + csv = tmp_path / "agents.csv" + csv.write_text("agent_id,current_x,current_y,rating\nA001,0,0,6.5\n") + agents = load_agents(str(csv)) + assert agents[0].rating == 5.0 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Issue 3: Load Environment Graph +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestEnvironmentGraph: + def test_loads_real_edges(self): + path = ROOT / "data" / "raw" / "environment_edges.csv" + edges = load_environment_edges(str(path)) + assert len(edges) == 180 + + def test_travel_time_adjacent(self, small_graph): + t = small_graph.travel_time((0, 0), (1, 0)) + assert t == pytest.approx(3.0, abs=0.01) + + def test_travel_time_diagonal(self, small_graph): + t = small_graph.travel_time((0, 0), (2, 2)) + assert t == pytest.approx(12.0, abs=0.01) # 4 hops * 3 min + + def test_no_path_returns_inf(self): + edges = [{"from": (0, 0), "to": (1, 0), "distance_minutes": 5, "delay_multiplier": 1.0}] + graph = EnvironmentGraph(edges) + assert graph.travel_time((0, 0), (9, 9)) == float("inf") + + def test_same_location_is_zero(self, small_graph): + assert small_graph.travel_time((2, 2), (2, 2)) == 0.0 + + def test_has_path(self, small_graph): + assert small_graph.has_path((0, 0), (4, 4)) + assert not small_graph.has_path((0, 0), (99, 99)) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Issue 4: Priority Order Queue +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPriorityOrderQueue: + def test_priority_ordering(self): + q = PriorityOrderQueue() + q.push(make_order("O1", "low")) + q.push(make_order("O2", "high")) + q.push(make_order("O3", "normal")) + top = q.peek_pending() + assert top.order_id == "O2" + + def test_fifo_within_same_priority(self): + q = PriorityOrderQueue() + q.push(make_order("O1", "high", ts_str="2026-05-03 09:00:00")) + q.push(make_order("O2", "high", ts_str="2026-05-03 09:05:00")) + first = q.pop_pending() + second = q.pop_pending() + assert first.order_id == "O1" + assert second.order_id == "O2" + + def test_state_transitions(self): + q = PriorityOrderQueue() + o = make_order("O1") + q.push(o) + at = datetime.now() + assert q.mark_assigned("O1", "A001", at) + assert q.get_order("O1").state == "ASSIGNED" + assert q.mark_in_transit("O1") + assert q.mark_delivered("O1", at, False) + assert q.get_order("O1").state == "DELIVERED" + + def test_invalid_transition_blocked(self): + q = PriorityOrderQueue() + q.push(make_order("O1")) + # Cannot go PENDING → IN_TRANSIT directly + assert not q.mark_in_transit("O1") + + def test_duplicate_ignored(self): + q = PriorityOrderQueue() + o = make_order("O1") + q.push(o) + q.push(o) # duplicate + assert len(q) == 1 + + def test_pending_count(self): + q = PriorityOrderQueue() + q.push_many([make_order(f"O{i}") for i in range(5)]) + assert q.pending_count() == 5 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Issue 5: Agent Registry +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestAgentRegistry: + def test_available_filtering(self): + agents = [make_agent("A1"), make_agent("A2")] + reg = AgentRegistry(agents, max_active=2) + reg.assign_order("A1", "O1") + reg.assign_order("A1", "O2") + avail = [a.agent_id for a in reg.available_agents()] + assert "A1" not in avail + assert "A2" in avail + + def test_cumulative_increments(self): + reg = AgentRegistry([make_agent("A1")], max_active=2) + reg.assign_order("A1", "O1") + reg.assign_order("A1", "O2") + assert reg.get("A1").cumulative_assignments == 2 + + def test_complete_order_updates_location(self): + reg = AgentRegistry([make_agent("A1")], max_active=2) + reg.assign_order("A1", "O1") + reg.complete_order("A1", "O1", new_location=(5, 5)) + assert reg.get("A1").current_location == (5, 5) + assert len(reg.get("A1").active_orders) == 0 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Issues 6, 7, 8: Scoring +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestScoring: + def test_no_candidates_when_all_full(self, small_graph, constraints): + agents = [make_agent("A1"), make_agent("A2")] + reg = AgentRegistry(agents, max_active=2) + reg.assign_order("A1", "O1"); reg.assign_order("A1", "O2") + reg.assign_order("A2", "O3"); reg.assign_order("A2", "O4") + order = make_order(lx=0, ly=0) + cands = generate_candidates(order, reg, small_graph) + assert len(cands) == 0 + + def test_unreachable_agents_excluded(self): + edges = [{"from": (0, 0), "to": (1, 0), "distance_minutes": 3, "delay_multiplier": 1.0}] + graph = EnvironmentGraph(edges) + agents = [make_agent("A1", cx=0, cy=0), make_agent("A2", cx=9, cy=9)] + reg = AgentRegistry(agents, max_active=2) + order = make_order(lx=1, ly=0) + cands = generate_candidates(order, reg, graph) + assert len(cands) == 1 + assert cands[0].agent.agent_id == "A1" + + def test_high_priority_gets_better_score(self, small_graph, constraints): + agents = [make_agent("A1", cx=0, cy=0), make_agent("A2", cx=0, cy=0)] + reg = AgentRegistry(agents, max_active=2) + order_h = make_order("OH", "high", lx=1, ly=0) + order_l = make_order("OL", "low", lx=1, ly=0) + cands_h = generate_candidates(order_h, reg, small_graph) + cands_l = generate_candidates(order_l, reg, small_graph) + scored_h = score_candidates(cands_h, constraints, reg)[0] + scored_l = score_candidates(cands_l, constraints, reg)[0] + assert scored_h.score < scored_l.score + + def test_select_best_returns_candidate(self, small_graph, constraints): + agents = [make_agent("A1")] + reg = AgentRegistry(agents, max_active=2) + order = make_order(lx=1, ly=1) + best = select_best_candidate(order, reg, small_graph, constraints) + assert best is not None + assert best.agent.agent_id == "A1" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Issues 9, 10, 11: Assignment and Delivery Simulation +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestDispatcher: + def _make_dispatcher(self, orders, agents, graph, constraints, metrics): + q = PriorityOrderQueue() + reg = AgentRegistry(agents, max_active=constraints.max_active_orders_per_agent) + d = Dispatcher(queue=q, registry=reg, graph=graph, + constraints=constraints, metrics=metrics) + d.run(orders, tick_minutes=1.0) + return d, q, reg + + def test_all_orders_delivered(self, small_graph, constraints): + orders = [make_order(f"O{i}", lx=1, ly=1) for i in range(5)] + agents = [make_agent(f"A{i}", cx=0, cy=0) for i in range(3)] + metrics = MetricsCollector() + d, q, reg = self._make_dispatcher(orders, agents, small_graph, constraints, metrics) + assert metrics.total_delivered == 5 + delivered = q.get_by_state("DELIVERED") + assert len(delivered) == 5 + + def test_no_sla_violations_simple(self, small_graph, constraints): + orders = [make_order("O1", priority="high", sla=100, prep=5, lx=1, ly=1)] + agents = [make_agent("A1", cx=0, cy=0)] + metrics = MetricsCollector() + self._make_dispatcher(orders, agents, small_graph, constraints, metrics) + assert metrics.sla_violations == 0 + + def test_queue_when_no_agents(self, small_graph, constraints): + """Issue 11: orders queue when all agents are full.""" + orders = [make_order(f"O{i}", lx=1, ly=1, prep=30) for i in range(4)] + agents = [make_agent("A1", cx=0, cy=0)] # one agent, max 2 active + metrics = MetricsCollector() + self._make_dispatcher(orders, agents, small_graph, constraints, metrics) + # All should eventually get delivered + assert metrics.total_delivered == 4 + + def test_full_real_dataset(self, real_graph): + """Integration test: run against actual CSV data.""" + orders = load_orders(str(ROOT / "data" / "raw" / "orders.csv")) + agents = load_agents(str(ROOT / "data" / "raw" / "agents.csv")) + constraints = load_constraints(str(ROOT / "data" / "raw" / "constraints.csv")) + metrics = MetricsCollector() + q = PriorityOrderQueue() + reg = AgentRegistry(agents, max_active=constraints.max_active_orders_per_agent) + d = Dispatcher(queue=q, registry=reg, graph=real_graph, + constraints=constraints, metrics=metrics) + d.run(orders, tick_minutes=1.0) + assert metrics.total_delivered == 150 + assert metrics.sla_compliance_rate == 1.0 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Issues 12, 13, 14: Metrics +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestMetrics: + def test_welford_mean(self): + rs = RunningStats() + for v in [2, 4, 6, 8, 10]: + rs.update(v) + assert rs.mean == pytest.approx(6.0, abs=1e-9) + + def test_welford_std(self): + rs = RunningStats() + for v in [2, 4, 4, 4, 5, 5, 7, 9]: + rs.update(v) + assert rs.std == pytest.approx(math.sqrt(4.0), abs=0.01) + + def test_sla_compliance_rate(self): + m = MetricsCollector() + m.record_delivery("O1", "high", 10, 50, False) + m.record_delivery("O2", "high", 60, 50, True) # violated + assert m.sla_compliance_rate == 0.5 + assert m.sla_violation_rate == 0.5 + + def test_fairness_metrics(self): + m = MetricsCollector() + m.record_assignment("A1") + m.record_assignment("A1") + m.record_assignment("A2") + fm = m._fairness_metrics() + assert fm["min_assignments"] == 1 + assert fm["max_assignments"] == 2 + assert fm["assignment_range"] == 1 + + def test_json_output(self): + m = MetricsCollector() + m.record_delivery("O1", "high", 12, 50, False) + d = m.to_dict() + assert "metadata" in d + assert "delivery_time" in d + assert "sla_compliance" in d + assert "workload_fairness" in d + # Must be JSON-serialisable + json.dumps(d) + + def test_latency_violation_logged(self, caplog): + import logging + m = MetricsCollector() + m.latency_target_ms = 100.0 + with caplog.at_level(logging.WARNING): + m.record_latency_ms(500.0) + assert any("latency" in r.message.lower() for r in caplog.records) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Issues 16, 17: Error Handling / Edge Cases +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestEdgeCases: + def test_empty_candidate_list_handled(self, small_graph, constraints): + """Issue 17: empty candidate list returns None without crashing.""" + agents = [make_agent("A1")] + reg = AgentRegistry(agents, max_active=2) + reg.assign_order("A1", "X1") + reg.assign_order("A1", "X2") + order = make_order(lx=1, ly=0) + result = select_best_candidate(order, reg, small_graph, constraints) + assert result is None + + def test_disconnected_location_no_crash(self): + """Issue 17: agent at disconnected node handled gracefully.""" + edges = [{"from": (0, 0), "to": (1, 0), "distance_minutes": 3, "delay_multiplier": 1.0}] + graph = EnvironmentGraph(edges) + agents = [make_agent("A1", cx=5, cy=5)] # not in graph + reg = AgentRegistry(agents, max_active=2) + constraints = Constraints() + order = make_order(lx=0, ly=0) + result = select_best_candidate(order, reg, graph, constraints) + assert result is None + + def test_malformed_row_skipped(self, tmp_path): + """Issue 16: malformed CSV rows skipped gracefully.""" + csv = tmp_path / "orders.csv" + csv.write_text( + "order_id,timestamp,location_x,location_y,prep_time_minutes,priority,sla_minutes\n" + "O001,BAD_DATE,1,1,10,high,50\n" + "O002,2026-05-03 09:00:00,1,1,10,high,50\n" + ) + orders = load_orders(str(csv)) + assert len(orders) == 1 + assert orders[0].order_id == "O002" + + def test_tie_score_deterministic(self, small_graph, constraints): + """Issue 17: ties broken deterministically by agent_id.""" + agents = [make_agent("A2", cx=0, cy=0), make_agent("A1", cx=0, cy=0)] + reg = AgentRegistry(agents, max_active=2) + order = make_order(lx=1, ly=0) + best = select_best_candidate(order, reg, small_graph, constraints) + assert best is not None # deterministic selection + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Issue 18: Performance / Latency +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPerformance: + def test_graph_query_fast(self, real_graph): + """Issue 18: travel_time queries well under 1ms after precompute.""" + import time + t0 = time.perf_counter() + for _ in range(1000): + real_graph.travel_time((0, 0), (9, 9)) + elapsed_ms = (time.perf_counter() - t0) * 1000 + assert elapsed_ms < 50 # 1000 queries in <50ms → <0.05ms each + + def test_assignment_latency(self, real_graph): + """Issue 18: single assignment decision under 500ms.""" + import time + agents = load_agents(str(ROOT / "data" / "raw" / "agents.csv")) + constraints = load_constraints(str(ROOT / "data" / "raw" / "constraints.csv")) + reg = AgentRegistry(agents, max_active=2) + order = make_order(lx=5, ly=5) + t0 = time.perf_counter() + select_best_candidate(order, reg, real_graph, constraints) + elapsed_ms = (time.perf_counter() - t0) * 1000 + assert elapsed_ms < 500