Kshitij2604 commited on
Commit
1236727
·
verified ·
1 Parent(s): ddf07cd

Upload 5 files

Browse files
Files changed (5) hide show
  1. Dockerfile +31 -0
  2. README.md +157 -10
  3. app.py +346 -0
  4. requirements.txt +8 -0
  5. solver.py +1049 -0
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies
6
+ RUN apt-get update && apt-get install -y \
7
+ gcc \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ # Copy requirements first to leverage Docker cache
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ # Copy the rest of the application
15
+ COPY . .
16
+
17
+ # Set environment variables
18
+ ENV PYTHONUNBUFFERED=1
19
+ ENV GRADIO_SERVER_NAME=0.0.0.0
20
+ ENV GRADIO_SERVER_PORT=7860
21
+
22
+ # Create a non-root user and switch to it
23
+ RUN useradd -m -u 1000 user && \
24
+ chown -R user:user /app
25
+ USER user
26
+
27
+ # Expose the port the app runs on
28
+ EXPOSE 7860
29
+
30
+ # Command to run the application
31
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,12 +1,159 @@
1
- ---
2
- title: CA
3
- emoji:
4
- colorFrom: gray
5
- colorTo: blue
6
- sdk: gradio
7
- sdk_version: 5.49.1
8
- app_file: app.py
9
- pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
+ # Ride-Sharing Optimizer (Enhanced VRP Solver) 🚗✨
2
+
3
+ A comprehensive Vehicle Routing Problem (VRP) solver designed for ride-sharing optimization with multiple algorithms and enhanced metrics. Deployable on Hugging Face Spaces!
4
+
5
+ [![Hugging Face Spaces](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Spaces-blue)](https://huggingface.co/spaces)
6
+
7
+ ## 🚀 Features
8
+
9
+ ### **Multiple Optimization Algorithms**
10
+ - **Greedy Algorithm**: Fast nearest neighbor construction (O(n²), good baseline)
11
+ - **Clarke-Wright Savings**: Industry standard savings-based merging (O(n³), high quality)
12
+ - **Genetic Algorithm**: Evolutionary meta-heuristic optimization (can escape local optima)
13
+
14
+ ### **Enhanced Validation & Error Handling**
15
+ - Input data validation (feasibility, format, constraints)
16
+ - Comprehensive error messages
17
+ - Graceful handling of edge cases
18
+
19
+ ### **Advanced Metrics & Performance Analysis**
20
+ - Load balancing (coefficient of variation)
21
+ - Distance balancing across vehicles
22
+ - Capacity utilization percentage
23
+ - Time window violation tracking
24
+ - **Real-time performance benchmarking**
25
+ - **Algorithm execution time tracking**
26
+ - **Composite solution quality scoring**
27
+ - **Side-by-side algorithm comparison**
28
+
29
+ ### **Robust Route Optimization**
30
+ - Nearest neighbor construction
31
+ - 2-opt local search improvement
32
+ - Distance matrix caching for performance
33
+
34
+ ## 📊 Quick Start
35
+
36
+ ### Installation
37
+ ```bash
38
+ pip install -r requirements.txt
39
+ ```
40
+
41
+ ### Run the Application
42
+ ```bash
43
+ python app.py
44
+ ```
45
+
46
+ The Gradio interface will open in your browser at `http://localhost:7860`
47
+
48
+ ## 🎯 Usage
49
+
50
+ ### 1. Generate Sample Data
51
+ - Set number of clients (riders) and vehicles (drivers)
52
+ - Choose clustering algorithm
53
+ - Adjust capacity and spatial parameters
54
+ - Generate and optimize automatically
55
+
56
+ ### 2. Upload Custom Data
57
+ Upload a CSV file with columns:
58
+ - `id`: Unique identifier for each stop
59
+ - `x`, `y`: Coordinates
60
+ - `demand`: Passenger/cargo demand at stop
61
+ - `tw_start`, `tw_end`: Time window (optional, soft constraints)
62
+ - `service`: Service time at stop (optional)
63
+
64
+ ### 3. Compare Algorithms
65
+ Use the **Algorithm Comparison** tab to run all algorithms on the same dataset:
66
+ - **Performance charts** with visual comparisons
67
+ - **Detailed metrics table** with all statistics
68
+ - **Execution time analysis** (ms and clients/second)
69
+ - **Quality scoring** (0-100 composite score)
70
+ - **Automatic recommendations** for best algorithm choice
71
+ - **Load balancing** (lower CV = better balance)
72
+ - **Capacity utilization** analysis
73
+
74
+ ## 🔧 Algorithm Details
75
+
76
+ ### Greedy Algorithm (Nearest Neighbor)
77
+ - Builds routes by always selecting nearest unvisited client
78
+ - Fast O(n²) complexity
79
+ - Good baseline performance, widely understood
80
+ - Can get trapped in local optima
81
+
82
+ ### Clarke-Wright Savings
83
+ - Calculates savings for merging individual client routes
84
+ - Classical O(n³) VRP algorithm, industry standard
85
+ - Often produces high-quality solutions
86
+ - Proven approach used in commercial software
87
+
88
+ ### Genetic Algorithm
89
+ - Evolutionary meta-heuristic with population-based search
90
+ - Uses crossover, mutation, and selection operators
91
+ - Can escape local optima and find excellent solutions
92
+ - Longer execution time but potentially better quality
93
+
94
+ ## 📈 Metrics Explained
95
+
96
+ ### **Solution Quality Metrics**
97
+ - **Distance Balance CV**: Lower values mean more balanced route lengths
98
+ - **Load Balance CV**: Lower values mean more balanced vehicle utilization
99
+ - **Capacity Utilization**: Percentage of total capacity used
100
+ - **Vehicles Used**: Actual vs available vehicles
101
+ - **Quality Score**: Composite score (0-100) combining distance, balance, and utilization
102
+
103
+ ### **Performance Metrics**
104
+ - **Execution Time**: Total solving time in milliseconds
105
+ - **Clients/Second**: Processing throughput rate
106
+ - **Algorithm Complexity**: Theoretical computational complexity
107
+ - **Component Timing**: Breakdown of validation, clustering, and routing time
108
+
109
+ ## 🛠️ Technical Implementation
110
+
111
+ ### Key Improvements Made:
112
+ 1. **Validation Function**: Prevents crashes with invalid data
113
+ 2. **Distance Matrix Caching**: Improves performance for large instances
114
+ 3. **Enhanced Metrics**: Better solution quality assessment
115
+ 4. **Multiple Algorithms**: Algorithm comparison capability
116
+ 5. **Error Handling**: Graceful failure with informative messages
117
+ 6. **Performance Benchmarking**: Real-time execution time tracking
118
+ 7. **Algorithm Comparison**: Side-by-side performance analysis
119
+ 8. **Quality Scoring**: Composite solution quality assessment
120
+ 9. **Visual Comparisons**: Performance charts and detailed statistics
121
+
122
+ ### File Structure:
123
+ ```
124
+ ├── app.py # Gradio UI interface
125
+ ├── solver.py # Core VRP algorithms and logic
126
+ ├── requirements.txt # Python dependencies
127
+ └── README.md # This file
128
+ ```
129
+
130
+ ## 🎨 Example Use Cases
131
+
132
+ - **Urban Ride-sharing**: Optimize driver routes for passenger pickups
133
+ - **Delivery Services**: Route optimization for package delivery
134
+ - **School Bus Routing**: Efficient student pickup/dropoff
135
+ - **Field Service**: Technician scheduling and routing
136
+ - **Waste Collection**: Garbage truck route optimization
137
+
138
+ ## 🔍 Performance Tips
139
+
140
+ - For large instances (>100 stops): Start with sweep algorithm
141
+ - For clustered data: Use k-means clustering
142
+ - For best quality: Try Clarke-Wright (slower but often better)
143
+ - Compare algorithms on your specific data patterns
144
+
145
+ ## 🚧 Future Enhancements
146
+
147
+ - Real-world distance APIs (Google Maps, OSRM)
148
+ - Hard time window constraints
149
+ - Multi-depot support
150
+ - Dynamic pricing models
151
+ - 3-opt and other advanced optimizations
152
+
153
+ ## 📝 License
154
+
155
+ Open source - feel free to modify and extend for your needs!
156
+
157
  ---
158
 
159
+ **Made with ❤️ using Python, Gradio, and optimization algorithms**
app.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import gradio as gr
4
+ import pandas as pd
5
+ from solver import (
6
+ generate_random_instance,
7
+ solve_vrp,
8
+ plot_solution,
9
+ parse_uploaded_csv,
10
+ make_template_dataframe,
11
+ compare_algorithms,
12
+ create_performance_chart,
13
+ )
14
+
15
+ # Ensure matplotlib uses 'Agg' backend for non-interactive environments
16
+ import matplotlib
17
+ matplotlib.use('Agg')
18
+
19
+ # Set environment variable for Gradio to work in Spaces
20
+ os.environ['GRADIO_TEMP_DIR'] = '/tmp/gradio'
21
+
22
+ TITLE = "Ride-Sharing Optimizer (Capacitated VRP) — Gradio Demo"
23
+ DESC = """
24
+ This demo assigns **stops (riders)** to **drivers (vehicles)** with multiple optimization algorithms.
25
+ You can **generate a sample** dataset or **upload a CSV** with columns:
26
+ `id,x,y,demand,tw_start,tw_end,service`.
27
+
28
+ **Algorithms:**
29
+ - **Greedy**: Nearest neighbor construction (fast, simple baseline)
30
+ - **Clarke-Wright**: Savings-based merging (industry standard, high quality)
31
+ - **Genetic**: Evolutionary optimization (meta-heuristic, can find excellent solutions)
32
+
33
+ - **Vehicle Capacity** = max total demand per vehicle (e.g., 4 passengers, 50 packages)
34
+ - **Random Seed** = controls data generation (same seed = same dataset for fair comparison)
35
+ - **Time windows** are *soft* (violations are reported in metrics)
36
+ - **Enhanced metrics** show load balancing and capacity utilization
37
+ - Distances are Euclidean on the X-Y plane for clarity
38
+
39
+ 💡 Tip: Try different algorithms to compare solution quality! Use the **Algorithm Comparison** tab for detailed analysis.
40
+ """
41
+
42
+ FOOTER = "Made with ❤️ using Gradio. Enhanced with validation, multiple algorithms, performance comparison, and detailed metrics. 🚀"
43
+
44
+
45
+ # -----------------------------
46
+ # Functions
47
+ # -----------------------------
48
+ def run_generator(n_clients, n_vehicles, capacity, spread, demand_min, demand_max, seed, algorithm):
49
+ try:
50
+ df = generate_random_instance(
51
+ n_clients=n_clients,
52
+ n_vehicles=n_vehicles,
53
+ capacity=capacity,
54
+ spread=spread,
55
+ demand_min=demand_min,
56
+ demand_max=demand_max,
57
+ seed=seed,
58
+ )
59
+ depot = (0.0, 0.0)
60
+ sol = solve_vrp(df, depot=depot, n_vehicles=n_vehicles, capacity=capacity, speed=1.0, algorithm=algorithm)
61
+
62
+ # ✅ plot_solution now returns a PIL image
63
+ img = plot_solution(df, sol, depot=depot)
64
+
65
+ route_table = sol["assignments_table"]
66
+
67
+ # Create quality scoring display
68
+ quality_display = create_quality_display(sol["metrics"], sol["performance"])
69
+
70
+ return img, route_table, quality_display, df
71
+ except Exception as e:
72
+ raise gr.Error(f"Error generating solution: {str(e)}")
73
+
74
+
75
+ def run_csv(file, n_vehicles, capacity, algorithm):
76
+ if file is None:
77
+ raise gr.Error("Please upload a CSV first.")
78
+ try:
79
+ df = parse_uploaded_csv(file)
80
+ except Exception as e:
81
+ raise gr.Error(f"CSV parsing error: {e}")
82
+
83
+ try:
84
+ depot = (0.0, 0.0)
85
+ sol = solve_vrp(df, depot=depot, n_vehicles=n_vehicles, capacity=capacity, speed=1.0, algorithm=algorithm)
86
+
87
+ img = plot_solution(df, sol, depot=depot)
88
+
89
+ route_table = sol["assignments_table"]
90
+
91
+ # Create quality scoring display
92
+ quality_display = create_quality_display(sol["metrics"], sol["performance"])
93
+ return img, route_table, quality_display
94
+ except Exception as e:
95
+ raise gr.Error(f"Error solving VRP: {str(e)}")
96
+
97
+
98
+ def download_template():
99
+ return make_template_dataframe()
100
+
101
+ def create_quality_display(metrics, performance):
102
+ """Create a formatted quality scoring display instead of raw JSON."""
103
+
104
+ # Extract key metrics
105
+ algorithm = metrics['algorithm'].title()
106
+ quality_score = metrics['solution_quality_score']
107
+ total_distance = metrics['total_distance']
108
+ vehicles_used = metrics['vehicles_used']
109
+ vehicles_available = metrics['vehicles_available']
110
+ capacity_utilization = metrics['capacity_utilization']
111
+ distance_balance_cv = metrics['distance_balance_cv']
112
+ load_balance_cv = metrics['load_balance_cv']
113
+ execution_time = performance['total_execution_time_ms']
114
+ clients_per_second = performance['clients_per_second']
115
+
116
+ # Calculate component scores for transparency
117
+ dist_score = max(0, 100 - (distance_balance_cv * 100))
118
+ load_score = max(0, 100 - (load_balance_cv * 100))
119
+ util_score = capacity_utilization
120
+
121
+ display = f"""
122
+ # 🎯 Solution Quality Report
123
+
124
+ ## 🏆 Overall Quality Score: **{quality_score}/100**
125
+
126
+ ### 📊 Score Breakdown:
127
+ - **Distance Balance Score**: {dist_score:.1f}/100 (30% weight)
128
+ - Lower route length variation = higher score
129
+ - Current CV: {distance_balance_cv:.3f}
130
+
131
+ - **Load Balance Score**: {load_score:.1f}/100 (30% weight)
132
+ - More even vehicle utilization = higher score
133
+ - Current CV: {load_balance_cv:.3f}
134
+
135
+ - **Capacity Utilization Score**: {util_score:.1f}/100 (40% weight)
136
+ - Higher resource efficiency = higher score
137
+ - Current utilization: {capacity_utilization:.1f}%
138
+
139
+ ### 🚀 Performance Metrics:
140
+ - **Algorithm**: {algorithm}
141
+ - **Execution Time**: {execution_time:.1f} ms
142
+ - **Processing Speed**: {clients_per_second:.1f} clients/second
143
+ - **Total Distance**: {total_distance:.1f} units
144
+ - **Vehicles Used**: {vehicles_used}/{vehicles_available}
145
+
146
+ ### 📈 Quality Calculation:
147
+ ```
148
+ Quality Score = (Distance Balance × 0.3) + (Load Balance × 0.3) + (Capacity Utilization × 0.4)
149
+ Quality Score = ({dist_score:.1f} × 0.3) + ({load_score:.1f} × 0.3) + ({util_score:.1f} × 0.4)
150
+ Quality Score = {dist_score * 0.3:.1f} + {load_score * 0.3:.1f} + {util_score * 0.4:.1f} = **{quality_score:.1f}**
151
+ ```
152
+
153
+ ### 💡 Interpretation:
154
+ - **90-100**: Excellent solution with great balance and efficiency
155
+ - **80-89**: Very good solution with minor optimization opportunities
156
+ - **70-79**: Good solution but could improve balance or utilization
157
+ - **60-69**: Acceptable solution with room for improvement
158
+ - **<60**: Poor solution, consider different algorithm or parameters
159
+ """
160
+
161
+ return display
162
+
163
+ def run_comparison(n_clients, n_vehicles, capacity, spread, demand_min, demand_max, seed):
164
+ """Run all algorithms and compare performance."""
165
+ try:
166
+ # Generate the same dataset for fair comparison
167
+ df = generate_random_instance(
168
+ n_clients=n_clients,
169
+ n_vehicles=n_vehicles,
170
+ capacity=capacity,
171
+ spread=spread,
172
+ demand_min=demand_min,
173
+ demand_max=demand_max,
174
+ seed=seed,
175
+ )
176
+ depot = (0.0, 0.0)
177
+
178
+ # Compare all algorithms
179
+ comparison = compare_algorithms(df, depot, n_vehicles, capacity)
180
+
181
+ # Create performance chart
182
+ chart = create_performance_chart(comparison)
183
+
184
+ # Create comparison table
185
+ comparison_data = []
186
+ for alg, data in comparison['results'].items():
187
+ if 'error' not in data:
188
+ comparison_data.append({
189
+ 'Algorithm': alg.title(),
190
+ 'Total Distance': data['total_distance'],
191
+ 'Vehicles Used': data['vehicles_used'],
192
+ 'Execution Time (ms)': data['execution_time_ms'],
193
+ 'Distance Balance CV': round(data['distance_balance_cv'], 3),
194
+ 'Load Balance CV': round(data['load_balance_cv'], 3),
195
+ 'Capacity Utilization (%)': data['capacity_utilization'],
196
+ 'Quality Score': data['quality_score'],
197
+ 'Clients/Second': data['clients_per_second'],
198
+ })
199
+ else:
200
+ comparison_data.append({
201
+ 'Algorithm': alg.title(),
202
+ 'Error': data['error'][:50] + '...' if len(data['error']) > 50 else data['error']
203
+ })
204
+
205
+ comparison_df = pd.DataFrame(comparison_data)
206
+
207
+ # Summary text
208
+ summary = f"""
209
+ **🏆 Performance Summary:**
210
+ - **Best Distance**: {comparison['best_distance'].title()} ({comparison['results'][comparison['best_distance']]['total_distance']:.1f} units)
211
+ - **Fastest**: {comparison['fastest'].title()} ({comparison['results'][comparison['fastest']]['execution_time_ms']:.1f} ms)
212
+ - **Best Balance**: {comparison['best_balance'].title()}
213
+ - **Highest Quality**: {comparison['best_quality'].title()} (Score: {comparison['results'][comparison['best_quality']]['quality_score']:.1f})
214
+
215
+ **💡 Recommendations:**
216
+ - For **speed**: Use {comparison['fastest'].title()}
217
+ - For **quality**: Use {comparison['best_quality'].title()}
218
+ - For **distance**: Use {comparison['best_distance'].title()}
219
+ """
220
+
221
+ return chart, comparison_df, summary, df
222
+
223
+ except Exception as e:
224
+ raise gr.Error(f"Error in comparison: {str(e)}")
225
+
226
+
227
+ # -----------------------------
228
+ # UI Layout
229
+ # -----------------------------
230
+ with gr.Blocks(title=TITLE) as demo:
231
+ gr.Markdown(f"# {TITLE}")
232
+ gr.Markdown(DESC)
233
+
234
+ with gr.Tab("🔀 Generate sample"):
235
+ with gr.Row():
236
+ with gr.Column():
237
+ n_clients = gr.Slider(5, 200, value=30, step=1, label="Number of riders (clients)")
238
+ n_vehicles = gr.Slider(1, 20, value=4, step=1, label="Number of drivers (vehicles)")
239
+ capacity = gr.Slider(1, 50, value=10, step=1, label="Vehicle capacity (max passengers/packages per vehicle)")
240
+ algorithm1 = gr.Dropdown(
241
+ choices=["greedy", "clarke_wright", "genetic"],
242
+ value="greedy",
243
+ label="Optimization Algorithm",
244
+ info="greedy=fast baseline, clarke_wright=industry standard, genetic=evolutionary meta-heuristic"
245
+ )
246
+ spread = gr.Slider(10, 200, value=50, step=1, label="Spatial spread (larger = wider map)")
247
+ demand_min = gr.Slider(1, 5, value=1, step=1, label="Min demand per stop")
248
+ demand_max = gr.Slider(1, 10, value=3, step=1, label="Max demand per stop")
249
+ seed = gr.Slider(0, 9999, value=42, step=1, label="Random seed")
250
+ run_btn = gr.Button("🚗 Generate & Optimize", variant="primary")
251
+ with gr.Column():
252
+ img = gr.Image(type="pil", label="Route Visualization", interactive=False)
253
+
254
+ with gr.Row():
255
+ route_df = gr.Dataframe(label="Route assignments (per stop)", wrap=True)
256
+ quality_report = gr.Markdown(label="Quality Scoring Report")
257
+ with gr.Accordion("Show generated dataset", open=False):
258
+ data_out = gr.Dataframe(label="Generated input data")
259
+
260
+ run_btn.click(
261
+ fn=run_generator,
262
+ inputs=[n_clients, n_vehicles, capacity, spread, demand_min, demand_max, seed, algorithm1],
263
+ outputs=[img, route_df, quality_report, data_out],
264
+ )
265
+
266
+ with gr.Tab("📄 Upload CSV"):
267
+ with gr.Row():
268
+ with gr.Column():
269
+ file = gr.File(label="Upload CSV (id,x,y,demand,tw_start,tw_end,service)")
270
+ dl_tmp = gr.Button("Get CSV Template")
271
+ n_vehicles2 = gr.Slider(1, 50, value=5, step=1, label="Number of drivers (vehicles)")
272
+ capacity2 = gr.Slider(1, 200, value=15, step=1, label="Vehicle capacity (max passengers/packages per vehicle)")
273
+ algorithm2 = gr.Dropdown(
274
+ choices=["greedy", "clarke_wright", "genetic"],
275
+ value="greedy",
276
+ label="Optimization Algorithm",
277
+ info="greedy=fast baseline, clarke_wright=industry standard, genetic=evolutionary meta-heuristic"
278
+ )
279
+ run_btn2 = gr.Button("📈 Optimize uploaded data", variant="primary")
280
+ with gr.Column():
281
+ img2 = gr.Image(type="pil", label="Route Visualization", interactive=False)
282
+
283
+ with gr.Row():
284
+ route_df2 = gr.Dataframe(label="Route assignments (per stop)")
285
+ quality_report2 = gr.Markdown(label="Quality Scoring Report")
286
+
287
+ run_btn2.click(
288
+ fn=run_csv,
289
+ inputs=[file, n_vehicles2, capacity2, algorithm2],
290
+ outputs=[img2, route_df2, quality_report2],
291
+ )
292
+
293
+ def _tmpl():
294
+ return gr.File.update(value=None), download_template()
295
+
296
+ dl_tmp.click(fn=_tmpl, outputs=[file, route_df2], inputs=None)
297
+
298
+ with gr.Tab("📊 Algorithm Comparison"):
299
+ gr.Markdown("""
300
+ ### 🔬 Performance Analysis
301
+ Compare all three algorithms on the same dataset to see which performs best for your specific scenario.
302
+
303
+ **Algorithm Characteristics:**
304
+ - **Greedy**: Fast O(n²), simple nearest neighbor, good baseline
305
+ - **Clarke-Wright**: Classical O(n³), industry standard, high quality solutions
306
+ - **Genetic**: Meta-heuristic O(g×p×n), evolutionary, can escape local optima
307
+
308
+ **What to look for:**
309
+ - **Distance**: Lower is better (more efficient routes)
310
+ - **Execution Time**: Lower is better (faster solving)
311
+ - **Balance CV**: Lower is better (more balanced workload)
312
+ - **Quality Score**: Higher is better (overall solution quality)
313
+ - **Capacity Utilization**: Higher is better (efficient resource use)
314
+ """)
315
+
316
+ with gr.Row():
317
+ with gr.Column():
318
+ comp_n_clients = gr.Slider(5, 100, value=30, step=1, label="Number of riders (clients)")
319
+ comp_n_vehicles = gr.Slider(1, 15, value=4, step=1, label="Number of drivers (vehicles)")
320
+ comp_capacity = gr.Slider(1, 30, value=10, step=1, label="Vehicle capacity")
321
+ comp_spread = gr.Slider(10, 100, value=50, step=1, label="Spatial spread")
322
+ comp_demand_min = gr.Slider(1, 3, value=1, step=1, label="Min demand per stop")
323
+ comp_demand_max = gr.Slider(1, 5, value=3, step=1, label="Max demand per stop")
324
+ comp_seed = gr.Slider(0, 999, value=42, step=1, label="Random seed")
325
+ compare_btn = gr.Button("🚀 Compare All Algorithms", variant="primary", size="lg")
326
+
327
+ with gr.Column():
328
+ comparison_chart = gr.Image(type="pil", label="Performance Comparison Chart", interactive=False)
329
+
330
+ with gr.Row():
331
+ comparison_table = gr.Dataframe(label="Detailed Comparison Results", wrap=True)
332
+ comparison_summary = gr.Markdown(label="Summary & Recommendations")
333
+
334
+ with gr.Accordion("Show comparison dataset", open=False):
335
+ comp_data_out = gr.Dataframe(label="Dataset used for comparison")
336
+
337
+ compare_btn.click(
338
+ fn=run_comparison,
339
+ inputs=[comp_n_clients, comp_n_vehicles, comp_capacity, comp_spread, comp_demand_min, comp_demand_max, comp_seed],
340
+ outputs=[comparison_chart, comparison_table, comparison_summary, comp_data_out],
341
+ )
342
+
343
+ gr.Markdown(f"---\n{FOOTER}")
344
+
345
+ if __name__ == "__main__":
346
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.0.0
2
+ pandas>=1.5.0
3
+ numpy>=1.21.0
4
+ matplotlib>=3.5.0
5
+ scikit-learn>=1.1.0
6
+ Pillow>=9.0.0
7
+ scipy>=1.8.0
8
+ typing-extensions>=4.0.0
solver.py ADDED
@@ -0,0 +1,1049 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import random
3
+ import time
4
+ from typing import Dict, List, Tuple, Optional
5
+ from functools import lru_cache
6
+
7
+ import matplotlib.pyplot as plt
8
+ import numpy as np
9
+ import pandas as pd
10
+ from sklearn.cluster import KMeans
11
+ from PIL import Image
12
+ import io
13
+
14
+
15
+ # ---------------------------
16
+ # Data utils
17
+ # ---------------------------
18
+
19
+ def make_template_dataframe():
20
+ """Blank template users can download/fill."""
21
+ return pd.DataFrame(
22
+ {
23
+ "id": ["A", "B", "C"],
24
+ "x": [10, -5, 15],
25
+ "y": [4, -12, 8],
26
+ "demand": [1, 2, 1],
27
+ "tw_start": [0, 0, 0], # optional: earliest arrival (soft)
28
+ "tw_end": [9999, 9999, 9999], # optional: latest arrival (soft)
29
+ "service": [0, 0, 0], # optional: service time at stop
30
+ }
31
+ )
32
+
33
+ def parse_uploaded_csv(file) -> pd.DataFrame:
34
+ df = pd.read_csv(file.name if hasattr(file, "name") else file)
35
+ required = {"id", "x", "y", "demand"}
36
+ missing = required - set(df.columns)
37
+ if missing:
38
+ raise ValueError(f"Missing required columns: {sorted(missing)}")
39
+
40
+ # fill optional columns if absent
41
+ if "tw_start" not in df.columns:
42
+ df["tw_start"] = 0
43
+ if "tw_end" not in df.columns:
44
+ df["tw_end"] = 999999
45
+ if "service" not in df.columns:
46
+ df["service"] = 0
47
+
48
+ # Normalize types
49
+ df["id"] = df["id"].astype(str)
50
+ for col in ["x", "y", "demand", "tw_start", "tw_end", "service"]:
51
+ df[col] = pd.to_numeric(df[col], errors="coerce")
52
+ df = df.dropna()
53
+ df.reset_index(drop=True, inplace=True)
54
+ return df
55
+
56
+ def generate_random_instance(
57
+ n_clients=30,
58
+ n_vehicles=4,
59
+ capacity=10,
60
+ spread=50,
61
+ demand_min=1,
62
+ demand_max=3,
63
+ seed=42,
64
+ ) -> pd.DataFrame:
65
+ rng = np.random.default_rng(seed)
66
+ xs = rng.uniform(-spread, spread, size=n_clients)
67
+ ys = rng.uniform(-spread, spread, size=n_clients)
68
+ demands = rng.integers(demand_min, demand_max + 1, size=n_clients)
69
+
70
+ df = pd.DataFrame(
71
+ {
72
+ "id": [f"C{i+1}" for i in range(n_clients)],
73
+ "x": xs,
74
+ "y": ys,
75
+ "demand": demands,
76
+ "tw_start": np.zeros(n_clients, dtype=float),
77
+ "tw_end": np.full(n_clients, 999999.0),
78
+ "service": np.zeros(n_clients, dtype=float),
79
+ }
80
+ )
81
+ return df
82
+
83
+
84
+
85
+ # Validation
86
+
87
+
88
+ def validate_instance(df: pd.DataFrame, n_vehicles: int, capacity: float) -> None:
89
+ """
90
+ Validate that the VRP instance is feasible and properly formatted.
91
+ Raises ValueError if validation fails.
92
+ """
93
+ # Check required columns
94
+ required = {'x', 'y', 'demand', 'tw_start', 'tw_end', 'service'}
95
+ missing = required - set(df.columns)
96
+ if missing:
97
+ raise ValueError(f"Missing required columns: {sorted(missing)}")
98
+
99
+ # Check for empty dataframe
100
+ if len(df) == 0:
101
+ raise ValueError("DataFrame is empty - no clients to route")
102
+
103
+ # Check for negative values in key fields
104
+ if (df['demand'] < 0).any():
105
+ raise ValueError("Negative demand values detected")
106
+
107
+ if (df['service'] < 0).any():
108
+ raise ValueError("Negative service time values detected")
109
+
110
+ # Check time window consistency
111
+ if (df['tw_start'] > df['tw_end']).any():
112
+ invalid_rows = df[df['tw_start'] > df['tw_end']]
113
+ raise ValueError(f"Invalid time windows (start > end) for {len(invalid_rows)} stops")
114
+
115
+ # Check feasibility: total demand vs total capacity
116
+ total_demand = df['demand'].sum()
117
+ total_capacity = n_vehicles * capacity
118
+
119
+ if total_demand > total_capacity:
120
+ raise ValueError(
121
+ f"Infeasible instance: total demand ({total_demand:.1f}) "
122
+ f"exceeds total capacity ({total_capacity:.1f})"
123
+ )
124
+
125
+ # Check for NaN or inf values
126
+ numeric_cols = ['x', 'y', 'demand', 'tw_start', 'tw_end', 'service']
127
+ if df[numeric_cols].isna().any().any():
128
+ raise ValueError("NaN values detected in numeric columns")
129
+
130
+ if np.isinf(df[numeric_cols].values).any():
131
+ raise ValueError("Infinite values detected in numeric columns")
132
+
133
+ # Warn if capacity is very small
134
+ if capacity <= 0:
135
+ raise ValueError("Vehicle capacity must be positive")
136
+
137
+ if n_vehicles <= 0:
138
+ raise ValueError("Number of vehicles must be positive")
139
+
140
+
141
+
142
+ # Geometry / distance helpers
143
+
144
+ def euclid(a: Tuple[float, float], b: Tuple[float, float]) -> float:
145
+ return float(math.hypot(a[0] - b[0], a[1] - b[1]))
146
+
147
+ def total_distance(points: List[Tuple[float, float]]) -> float:
148
+ return sum(euclid(points[i], points[i + 1]) for i in range(len(points) - 1))
149
+
150
+ def build_distance_matrix(df: pd.DataFrame, depot: Tuple[float, float]) -> np.ndarray:
151
+ """
152
+ Build a distance matrix for all clients and depot.
153
+ Matrix[i][j] = distance from i to j, where 0 is depot.
154
+ Returns (n+1) x (n+1) matrix.
155
+ """
156
+ n = len(df)
157
+ points = [depot] + [(df.loc[i, 'x'], df.loc[i, 'y']) for i in range(n)]
158
+
159
+ dist_matrix = np.zeros((n + 1, n + 1))
160
+ for i in range(n + 1):
161
+ for j in range(i + 1, n + 1):
162
+ d = euclid(points[i], points[j])
163
+ dist_matrix[i][j] = d
164
+ dist_matrix[j][i] = d
165
+
166
+ return dist_matrix
167
+
168
+
169
+
170
+ # Clustering algorithms
171
+
172
+
173
+ def sweep_clusters(
174
+ df: pd.DataFrame,
175
+ depot: Tuple[float, float],
176
+ n_vehicles: int,
177
+ capacity: float,
178
+ ) -> List[List[int]]:
179
+ """
180
+ Assign clients to vehicles by angular sweep around the depot, roughly balancing
181
+ capacity (sum of 'demand').
182
+ Returns indices (row numbers) per cluster.
183
+ """
184
+ dx = df["x"].values - depot[0]
185
+ dy = df["y"].values - depot[1]
186
+ ang = np.arctan2(dy, dx)
187
+ order = np.argsort(ang)
188
+
189
+ clusters: List[List[int]] = [[] for _ in range(n_vehicles)]
190
+ loads = [0.0] * n_vehicles
191
+ v = 0
192
+ for idx in order:
193
+ d = float(df.loc[idx, "demand"])
194
+ # if adding to current vehicle exceeds capacity *by a lot*, move to next
195
+ if loads[v] + d > capacity and v < n_vehicles - 1:
196
+ v += 1
197
+ clusters[v].append(int(idx))
198
+ loads[v] += d
199
+
200
+ return clusters
201
+
202
+ def kmeans_clusters(
203
+ df: pd.DataFrame,
204
+ depot: Tuple[float, float],
205
+ n_vehicles: int,
206
+ capacity: float,
207
+ random_state: int = 42,
208
+ ) -> List[List[int]]:
209
+ """
210
+ Assign clients using k-means clustering, then balance capacity.
211
+ Returns indices (row numbers) per cluster.
212
+ """
213
+ if len(df) == 0:
214
+ return [[] for _ in range(n_vehicles)]
215
+
216
+ # K-means clustering
217
+ X = df[['x', 'y']].values
218
+ kmeans = KMeans(n_clusters=min(n_vehicles, len(df)), random_state=random_state, n_init=10)
219
+ labels = kmeans.fit_predict(X)
220
+
221
+ # Group by cluster
222
+ initial_clusters: List[List[int]] = [[] for _ in range(n_vehicles)]
223
+ for idx, label in enumerate(labels):
224
+ initial_clusters[label].append(idx)
225
+
226
+ # Balance capacity: if a cluster exceeds capacity, split it
227
+ balanced_clusters: List[List[int]] = []
228
+ for cluster in initial_clusters:
229
+ if not cluster:
230
+ continue
231
+
232
+ # Sort by angle from depot for deterministic splitting
233
+ cluster_sorted = sorted(cluster, key=lambda i: np.arctan2(
234
+ df.loc[i, 'y'] - depot[1],
235
+ df.loc[i, 'x'] - depot[0]
236
+ ))
237
+
238
+ current = []
239
+ current_load = 0.0
240
+ for idx in cluster_sorted:
241
+ demand = float(df.loc[idx, 'demand'])
242
+ if current_load + demand > capacity and current:
243
+ balanced_clusters.append(current)
244
+ current = [idx]
245
+ current_load = demand
246
+ else:
247
+ current.append(idx)
248
+ current_load += demand
249
+
250
+ if current:
251
+ balanced_clusters.append(current)
252
+
253
+ # Pad with empty clusters if needed
254
+ while len(balanced_clusters) < n_vehicles:
255
+ balanced_clusters.append([])
256
+
257
+ return balanced_clusters[:n_vehicles]
258
+
259
+ def clarke_wright_savings(
260
+ df: pd.DataFrame,
261
+ depot: Tuple[float, float],
262
+ n_vehicles: int,
263
+ capacity: float,
264
+ ) -> List[List[int]]:
265
+ """
266
+ Clarke-Wright Savings algorithm for VRP clustering.
267
+ Returns indices (row numbers) per route.
268
+ """
269
+ n = len(df)
270
+ if n == 0:
271
+ return [[] for _ in range(n_vehicles)]
272
+
273
+ # Build distance matrix
274
+ dist_matrix = build_distance_matrix(df, depot)
275
+
276
+ # Calculate savings: s(i,j) = d(0,i) + d(0,j) - d(i,j)
277
+ savings = []
278
+ for i in range(1, n + 1):
279
+ for j in range(i + 1, n + 1):
280
+ s = dist_matrix[0][i] + dist_matrix[0][j] - dist_matrix[i][j]
281
+ savings.append((s, i - 1, j - 1)) # Convert to 0-indexed client ids
282
+
283
+ # Sort by savings (descending)
284
+ savings.sort(reverse=True)
285
+
286
+ # Initialize: each client in its own route
287
+ routes: List[List[int]] = [[i] for i in range(n)]
288
+ route_loads = [float(df.loc[i, 'demand']) for i in range(n)]
289
+
290
+ # Track which route each client belongs to
291
+ client_to_route = {i: i for i in range(n)}
292
+
293
+ # Merge routes based on savings
294
+ for saving, i, j in savings:
295
+ route_i = client_to_route[i]
296
+ route_j = client_to_route[j]
297
+
298
+ # Skip if already in same route
299
+ if route_i == route_j:
300
+ continue
301
+
302
+ # Check capacity constraint
303
+ if route_loads[route_i] + route_loads[route_j] > capacity:
304
+ continue
305
+
306
+ # Check if i and j are at ends of their respective routes
307
+ ri = routes[route_i]
308
+ rj = routes[route_j]
309
+
310
+ # Only merge if they're at route ends
311
+ i_at_end = (i == ri[0] or i == ri[-1])
312
+ j_at_end = (j == rj[0] or j == rj[-1])
313
+
314
+ if not (i_at_end and j_at_end):
315
+ continue
316
+
317
+ # Merge routes
318
+ if i == ri[-1] and j == rj[0]:
319
+ new_route = ri + rj
320
+ elif i == ri[0] and j == rj[-1]:
321
+ new_route = rj + ri
322
+ elif i == ri[-1] and j == rj[-1]:
323
+ new_route = ri + rj[::-1]
324
+ elif i == ri[0] and j == rj[0]:
325
+ new_route = ri[::-1] + rj
326
+ else:
327
+ continue
328
+
329
+ # Update routes
330
+ routes[route_i] = new_route
331
+ route_loads[route_i] += route_loads[route_j]
332
+ routes[route_j] = []
333
+ route_loads[route_j] = 0.0
334
+
335
+ # Update client mapping
336
+ for client in rj:
337
+ client_to_route[client] = route_i
338
+
339
+ # Filter out empty routes and limit to n_vehicles
340
+ final_routes = [r for r in routes if r]
341
+
342
+ # If too many routes, merge smallest ones
343
+ while len(final_routes) > n_vehicles:
344
+ # Find two smallest routes that can be merged
345
+ route_sizes = [(sum(df.loc[r, 'demand']), idx) for idx, r in enumerate(final_routes)]
346
+ route_sizes.sort()
347
+
348
+ merged = False
349
+ for i in range(len(route_sizes)):
350
+ for j in range(i + 1, len(route_sizes)):
351
+ idx1, idx2 = route_sizes[i][1], route_sizes[j][1]
352
+ if route_sizes[i][0] + route_sizes[j][0] <= capacity:
353
+ final_routes[idx1].extend(final_routes[idx2])
354
+ final_routes.pop(idx2)
355
+ merged = True
356
+ break
357
+ if merged:
358
+ break
359
+
360
+ if not merged:
361
+ # Force merge smallest two even if over capacity
362
+ idx1, idx2 = route_sizes[0][1], route_sizes[1][1]
363
+ final_routes[idx1].extend(final_routes[idx2])
364
+ final_routes.pop(idx2)
365
+
366
+ # Pad with empty routes if needed
367
+ while len(final_routes) < n_vehicles:
368
+ final_routes.append([])
369
+
370
+ return final_routes
371
+
372
+ def greedy_nearest_neighbor(
373
+ df: pd.DataFrame,
374
+ depot: Tuple[float, float],
375
+ n_vehicles: int,
376
+ capacity: float,
377
+ ) -> List[List[int]]:
378
+ """
379
+ Greedy Nearest Neighbor algorithm for VRP.
380
+ Build routes by always adding the nearest unvisited client.
381
+ """
382
+ n = len(df)
383
+ if n == 0:
384
+ return [[] for _ in range(n_vehicles)]
385
+
386
+ unvisited = set(range(n))
387
+ routes = []
388
+
389
+ while unvisited and len(routes) < n_vehicles:
390
+ route = []
391
+ current_load = 0.0
392
+ current_pos = depot
393
+
394
+ while unvisited:
395
+ # Find nearest unvisited client that fits in current vehicle
396
+ best_client = None
397
+ best_distance = float('inf')
398
+
399
+ for client_idx in unvisited:
400
+ client_pos = (df.loc[client_idx, 'x'], df.loc[client_idx, 'y'])
401
+ client_demand = df.loc[client_idx, 'demand']
402
+
403
+ # Check capacity constraint
404
+ if current_load + client_demand <= capacity:
405
+ distance = euclid(current_pos, client_pos)
406
+ if distance < best_distance:
407
+ best_distance = distance
408
+ best_client = client_idx
409
+
410
+ if best_client is None:
411
+ break # No more clients fit in this vehicle
412
+
413
+ # Add client to route
414
+ route.append(best_client)
415
+ unvisited.remove(best_client)
416
+ current_load += df.loc[best_client, 'demand']
417
+ current_pos = (df.loc[best_client, 'x'], df.loc[best_client, 'y'])
418
+
419
+ if route:
420
+ routes.append(route)
421
+
422
+ # If there are remaining unvisited clients, force them into existing routes
423
+ route_idx = 0
424
+ for client_idx in list(unvisited):
425
+ if route_idx < len(routes):
426
+ routes[route_idx].append(client_idx)
427
+ route_idx = (route_idx + 1) % len(routes)
428
+
429
+ # Pad with empty routes if needed
430
+ while len(routes) < n_vehicles:
431
+ routes.append([])
432
+
433
+ return routes[:n_vehicles]
434
+
435
+ def genetic_algorithm_vrp(
436
+ df: pd.DataFrame,
437
+ depot: Tuple[float, float],
438
+ n_vehicles: int,
439
+ capacity: float,
440
+ population_size: int = 50,
441
+ generations: int = 100,
442
+ mutation_rate: float = 0.1,
443
+ elite_size: int = 10,
444
+ ) -> List[List[int]]:
445
+ """
446
+ Genetic Algorithm for VRP.
447
+ Evolves a population of solutions over multiple generations.
448
+ """
449
+ n = len(df)
450
+ if n == 0:
451
+ return [[] for _ in range(n_vehicles)]
452
+
453
+ # Individual representation: list of client indices with vehicle separators
454
+ # Use -1 as vehicle separator
455
+
456
+ def create_individual():
457
+ """Create a random individual (solution)."""
458
+ clients = list(range(n))
459
+ random.shuffle(clients)
460
+
461
+ # Insert vehicle separators randomly
462
+ individual = []
463
+ separators_added = 0
464
+
465
+ for i, client in enumerate(clients):
466
+ individual.append(client)
467
+ # Add separator with some probability, but ensure we don't exceed n_vehicles-1 separators
468
+ if (separators_added < n_vehicles - 1 and
469
+ i < len(clients) - 1 and
470
+ random.random() < 0.3):
471
+ individual.append(-1)
472
+ separators_added += 1
473
+
474
+ return individual
475
+
476
+ def individual_to_routes(individual):
477
+ """Convert individual to route format."""
478
+ routes = []
479
+ current_route = []
480
+
481
+ for gene in individual:
482
+ if gene == -1: # Vehicle separator
483
+ if current_route:
484
+ routes.append(current_route)
485
+ current_route = []
486
+ else:
487
+ current_route.append(gene)
488
+
489
+ if current_route:
490
+ routes.append(current_route)
491
+
492
+ # Pad with empty routes
493
+ while len(routes) < n_vehicles:
494
+ routes.append([])
495
+
496
+ return routes[:n_vehicles]
497
+
498
+ def calculate_fitness(individual):
499
+ """Calculate fitness (lower is better, so we'll use negative total distance)."""
500
+ routes = individual_to_routes(individual)
501
+ total_distance = 0.0
502
+ penalty = 0.0
503
+
504
+ for route in routes:
505
+ if not route:
506
+ continue
507
+
508
+ # Check capacity constraint
509
+ route_load = sum(df.loc[i, 'demand'] for i in route)
510
+ if route_load > capacity:
511
+ penalty += (route_load - capacity) * 1000 # Heavy penalty
512
+
513
+ # Calculate route distance
514
+ points = [depot] + [(df.loc[i, 'x'], df.loc[i, 'y']) for i in route] + [depot]
515
+ route_distance = total_distance_func(points)
516
+ total_distance += route_distance
517
+
518
+ return -(total_distance + penalty) # Negative because we want to minimize
519
+
520
+ def total_distance_func(points):
521
+ """Calculate total distance for a sequence of points."""
522
+ return sum(euclid(points[i], points[i + 1]) for i in range(len(points) - 1))
523
+
524
+ def crossover(parent1, parent2):
525
+ """Order crossover (OX) for VRP."""
526
+ # Remove separators for crossover
527
+ p1_clients = [g for g in parent1 if g != -1]
528
+ p2_clients = [g for g in parent2 if g != -1]
529
+
530
+ if len(p1_clients) < 2:
531
+ return parent1[:], parent2[:]
532
+
533
+ # Standard order crossover
534
+ size = len(p1_clients)
535
+ start, end = sorted(random.sample(range(size), 2))
536
+
537
+ child1 = [None] * size
538
+ child2 = [None] * size
539
+
540
+ # Copy segments
541
+ child1[start:end] = p1_clients[start:end]
542
+ child2[start:end] = p2_clients[start:end]
543
+
544
+ # Fill remaining positions
545
+ def fill_child(child, other_parent):
546
+ remaining = [x for x in other_parent if x not in child]
547
+ j = 0
548
+ for i in range(size):
549
+ if child[i] is None:
550
+ child[i] = remaining[j]
551
+ j += 1
552
+
553
+ fill_child(child1, p2_clients)
554
+ fill_child(child2, p1_clients)
555
+
556
+ # Add separators back randomly
557
+ def add_separators(child):
558
+ result = []
559
+ separators_to_add = min(n_vehicles - 1, len(child) // 3)
560
+ separator_positions = random.sample(range(1, len(child)), separators_to_add)
561
+
562
+ for i, gene in enumerate(child):
563
+ if i in separator_positions:
564
+ result.append(-1)
565
+ result.append(gene)
566
+
567
+ return result
568
+
569
+ return add_separators(child1), add_separators(child2)
570
+
571
+ def mutate(individual):
572
+ """Mutation: swap two random clients."""
573
+ if random.random() > mutation_rate:
574
+ return individual
575
+
576
+ clients = [i for i, g in enumerate(individual) if g != -1]
577
+ if len(clients) < 2:
578
+ return individual
579
+
580
+ # Swap two random clients
581
+ idx1, idx2 = random.sample(clients, 2)
582
+ individual = individual[:]
583
+ individual[idx1], individual[idx2] = individual[idx2], individual[idx1]
584
+
585
+ return individual
586
+
587
+ # Initialize population
588
+ population = [create_individual() for _ in range(population_size)]
589
+
590
+ # Evolution
591
+ for generation in range(generations):
592
+ # Calculate fitness for all individuals
593
+ fitness_scores = [(individual, calculate_fitness(individual)) for individual in population]
594
+ fitness_scores.sort(key=lambda x: x[1], reverse=True) # Higher fitness is better
595
+
596
+ # Select elite
597
+ elite = [individual for individual, _ in fitness_scores[:elite_size]]
598
+
599
+ # Create new population
600
+ new_population = elite[:]
601
+
602
+ while len(new_population) < population_size:
603
+ # Tournament selection
604
+ tournament_size = 5
605
+ parent1 = max(random.sample(fitness_scores, min(tournament_size, len(fitness_scores))),
606
+ key=lambda x: x[1])[0]
607
+ parent2 = max(random.sample(fitness_scores, min(tournament_size, len(fitness_scores))),
608
+ key=lambda x: x[1])[0]
609
+
610
+ # Crossover
611
+ child1, child2 = crossover(parent1, parent2)
612
+
613
+ # Mutation
614
+ child1 = mutate(child1)
615
+ child2 = mutate(child2)
616
+
617
+ new_population.extend([child1, child2])
618
+
619
+ population = new_population[:population_size]
620
+
621
+ # Return best solution
622
+ final_fitness = [(individual, calculate_fitness(individual)) for individual in population]
623
+ best_individual = max(final_fitness, key=lambda x: x[1])[0]
624
+
625
+ return individual_to_routes(best_individual)
626
+
627
+
628
+
629
+ # Performance and Quality Analysis
630
+
631
+
632
+ def get_algorithm_complexity(algorithm: str, n_clients: int) -> str:
633
+ """
634
+ Return theoretical complexity of the algorithm.
635
+ """
636
+ if algorithm == 'greedy':
637
+ return f"O(n²) ≈ {n_clients**2:.0f} ops"
638
+ elif algorithm == 'clarke_wright':
639
+ return f"O(n³) ≈ {n_clients**3:.0f} ops"
640
+ elif algorithm == 'genetic':
641
+ return f"O(g×p×n) ≈ {100 * 50 * n_clients:.0f} ops (100 gen, 50 pop)"
642
+ else:
643
+ return "Unknown"
644
+
645
+ def calculate_solution_quality_score(total_dist: float, dist_balance: float, load_balance: float, capacity_util: float) -> float:
646
+ """
647
+ Calculate a composite quality score (0-100, higher is better).
648
+ Considers distance efficiency, balance, and utilization.
649
+ """
650
+ # Normalize components (lower CV and higher utilization are better)
651
+ dist_score = max(0, 100 - (dist_balance * 100)) # Lower CV = higher score
652
+ load_score = max(0, 100 - (load_balance * 100)) # Lower CV = higher score
653
+ util_score = capacity_util * 100 # Higher utilization = higher score
654
+
655
+ # Weighted average
656
+ quality_score = (dist_score * 0.3 + load_score * 0.3 + util_score * 0.4)
657
+ return round(quality_score, 1)
658
+
659
+ def compare_algorithms(df: pd.DataFrame, depot: Tuple[float, float], n_vehicles: int, capacity: float) -> Dict:
660
+ """
661
+ Run all algorithms on the same instance and compare results.
662
+ """
663
+ algorithms = ['greedy', 'clarke_wright', 'genetic']
664
+ results = {}
665
+
666
+ for alg in algorithms:
667
+ try:
668
+ result = solve_vrp(df, depot, n_vehicles, capacity, algorithm=alg)
669
+ results[alg] = {
670
+ 'total_distance': result['metrics']['total_distance'],
671
+ 'vehicles_used': result['metrics']['vehicles_used'],
672
+ 'execution_time_ms': result['performance']['total_execution_time_ms'],
673
+ 'distance_balance_cv': result['metrics']['distance_balance_cv'],
674
+ 'load_balance_cv': result['metrics']['load_balance_cv'],
675
+ 'capacity_utilization': result['metrics']['capacity_utilization'],
676
+ 'quality_score': result['metrics']['solution_quality_score'],
677
+ 'clients_per_second': result['performance']['clients_per_second'],
678
+ }
679
+ except Exception as e:
680
+ results[alg] = {'error': str(e)}
681
+
682
+ # Find best performer in each category
683
+ comparison = {
684
+ 'results': results,
685
+ 'best_distance': min(results.keys(), key=lambda k: results[k].get('total_distance', float('inf')) if 'error' not in results[k] else float('inf')),
686
+ 'fastest': min(results.keys(), key=lambda k: results[k].get('execution_time_ms', float('inf')) if 'error' not in results[k] else float('inf')),
687
+ 'best_balance': min(results.keys(), key=lambda k: (results[k].get('distance_balance_cv', float('inf')) + results[k].get('load_balance_cv', float('inf'))) if 'error' not in results[k] else float('inf')),
688
+ 'best_quality': max(results.keys(), key=lambda k: results[k].get('quality_score', 0) if 'error' not in results[k] else 0),
689
+ }
690
+
691
+ return comparison
692
+
693
+ def create_performance_chart(comparison_data: Dict) -> Image.Image:
694
+ """
695
+ Create a performance comparison chart.
696
+ """
697
+ algorithms = list(comparison_data['results'].keys())
698
+ valid_algs = [alg for alg in algorithms if 'error' not in comparison_data['results'][alg]]
699
+
700
+ if not valid_algs:
701
+ # Create empty chart if no valid results
702
+ fig, ax = plt.subplots(figsize=(8, 6))
703
+ ax.text(0.5, 0.5, 'No valid results to display', ha='center', va='center', transform=ax.transAxes)
704
+ ax.set_title('Algorithm Comparison - No Data')
705
+ buf = io.BytesIO()
706
+ fig.savefig(buf, format='png', bbox_inches='tight')
707
+ plt.close(fig)
708
+ buf.seek(0)
709
+ return Image.open(buf)
710
+
711
+ # Create subplots for different metrics
712
+ fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(12, 10))
713
+
714
+ # Distance comparison
715
+ distances = [comparison_data['results'][alg]['total_distance'] for alg in valid_algs]
716
+ bars1 = ax1.bar(valid_algs, distances, color=['#1f77b4', '#ff7f0e', '#2ca02c'][:len(valid_algs)])
717
+ ax1.set_title('Total Distance')
718
+ ax1.set_ylabel('Distance')
719
+ best_dist_alg = comparison_data['best_distance']
720
+ if best_dist_alg in valid_algs:
721
+ bars1[valid_algs.index(best_dist_alg)].set_color('#d62728')
722
+
723
+ # Execution time comparison
724
+ times = [comparison_data['results'][alg]['execution_time_ms'] for alg in valid_algs]
725
+ bars2 = ax2.bar(valid_algs, times, color=['#1f77b4', '#ff7f0e', '#2ca02c'][:len(valid_algs)])
726
+ ax2.set_title('Execution Time')
727
+ ax2.set_ylabel('Time (ms)')
728
+ fastest_alg = comparison_data['fastest']
729
+ if fastest_alg in valid_algs:
730
+ bars2[valid_algs.index(fastest_alg)].set_color('#d62728')
731
+
732
+ # Quality score comparison
733
+ quality_scores = [comparison_data['results'][alg]['quality_score'] for alg in valid_algs]
734
+ bars3 = ax3.bar(valid_algs, quality_scores, color=['#1f77b4', '#ff7f0e', '#2ca02c'][:len(valid_algs)])
735
+ ax3.set_title('Solution Quality Score')
736
+ ax3.set_ylabel('Score (0-100)')
737
+ best_quality_alg = comparison_data['best_quality']
738
+ if best_quality_alg in valid_algs:
739
+ bars3[valid_algs.index(best_quality_alg)].set_color('#d62728')
740
+
741
+ # Capacity utilization comparison
742
+ utilizations = [comparison_data['results'][alg]['capacity_utilization'] for alg in valid_algs]
743
+ bars4 = ax4.bar(valid_algs, utilizations, color=['#1f77b4', '#ff7f0e', '#2ca02c'][:len(valid_algs)])
744
+ ax4.set_title('Capacity Utilization')
745
+ ax4.set_ylabel('Utilization (%)')
746
+
747
+ plt.tight_layout()
748
+
749
+ # Convert to PIL Image
750
+ buf = io.BytesIO()
751
+ fig.savefig(buf, format='png', bbox_inches='tight', dpi=100)
752
+ plt.close(fig)
753
+ buf.seek(0)
754
+ return Image.open(buf)
755
+
756
+
757
+
758
+ # Route construction + 2-opt
759
+
760
+
761
+ def nearest_neighbor_route(
762
+ pts: List[Tuple[float, float]],
763
+ start_idx: int = 0,
764
+ ) -> List[int]:
765
+ n = len(pts)
766
+ unvisited = set(range(n))
767
+ route = [start_idx]
768
+ unvisited.remove(start_idx)
769
+ while unvisited:
770
+ last = route[-1]
771
+ nxt = min(unvisited, key=lambda j: euclid(pts[last], pts[j]))
772
+ route.append(nxt)
773
+ unvisited.remove(nxt)
774
+ return route
775
+
776
+ def two_opt(route: List[int], pts: List[Tuple[float, float]], max_iter=200) -> List[int]:
777
+ best = route[:]
778
+ best_len = total_distance([pts[i] for i in best])
779
+ n = len(route)
780
+ improved = True
781
+ it = 0
782
+ while improved and it < max_iter:
783
+ improved = False
784
+ it += 1
785
+ for i in range(1, n - 2):
786
+ for k in range(i + 1, n - 1):
787
+ new_route = best[:i] + best[i:k + 1][::-1] + best[k + 1:]
788
+ new_len = total_distance([pts[i] for i in new_route])
789
+ if new_len + 1e-9 < best_len:
790
+ best, best_len = new_route, new_len
791
+ improved = True
792
+ if improved is False:
793
+ break
794
+ return best
795
+
796
+ def build_route_for_cluster(
797
+ df: pd.DataFrame,
798
+ idxs: List[int],
799
+ depot: Tuple[float, float],
800
+ ) -> List[int]:
801
+ """
802
+ Build a TSP tour over cluster points and return client indices in visiting order.
803
+ Returns client indices (not including the depot) but representing the order.
804
+ """
805
+ # Local point list: depot at 0, then cluster in order
806
+ pts = [depot] + [(float(df.loc[i, "x"]), float(df.loc[i, "y"])) for i in idxs]
807
+ # Greedy tour over all nodes
808
+ rr = nearest_neighbor_route(pts, start_idx=0)
809
+ # Ensure route starts at 0 and ends at 0 conceptually; we'll remove the 0s later
810
+ # Optimize with 2-opt, but keep depot fixed by converting to a path that starts at 0
811
+ rr = two_opt(rr, pts)
812
+ # remove the depot index 0 from the sequence (keep order of clients)
813
+ order = [idxs[i - 1] for i in rr if i != 0]
814
+ return order
815
+
816
+
817
+
818
+ # Solve wrapper
819
+ # ---------------------------
820
+
821
+ def solve_vrp(
822
+ df: pd.DataFrame,
823
+ depot: Tuple[float, float] = (0.0, 0.0),
824
+ n_vehicles: int = 4,
825
+ capacity: float = 10,
826
+ speed: float = 1.0,
827
+ algorithm: str = 'sweep',
828
+ ) -> Dict:
829
+ """
830
+ Solve VRP using specified algorithm with performance tracking.
831
+
832
+ Args:
833
+ df: Client data
834
+ depot: Depot coordinates
835
+ n_vehicles: Number of vehicles
836
+ capacity: Vehicle capacity
837
+ speed: Travel speed for time calculations
838
+ algorithm: Clustering algorithm ('greedy', 'clarke_wright', 'genetic')
839
+
840
+ Returns:
841
+ {
842
+ 'routes': List[List[int]] (row indices of df),
843
+ 'total_distance': float,
844
+ 'per_route_distance': List[float],
845
+ 'assignments_table': pd.DataFrame,
846
+ 'metrics': dict,
847
+ 'performance': dict
848
+ }
849
+ """
850
+ start_time = time.time()
851
+
852
+ # Validate instance
853
+ validate_instance(df, n_vehicles, capacity)
854
+ validation_time = time.time() - start_time
855
+
856
+ # 1) cluster using selected algorithm
857
+ clustering_start = time.time()
858
+ if algorithm == 'greedy':
859
+ clusters = greedy_nearest_neighbor(df, depot=depot, n_vehicles=n_vehicles, capacity=capacity)
860
+ elif algorithm == 'clarke_wright':
861
+ clusters = clarke_wright_savings(df, depot=depot, n_vehicles=n_vehicles, capacity=capacity)
862
+ elif algorithm == 'genetic':
863
+ clusters = genetic_algorithm_vrp(df, depot=depot, n_vehicles=n_vehicles, capacity=capacity)
864
+ else:
865
+ raise ValueError(f"Unknown algorithm: {algorithm}. Use 'greedy', 'clarke_wright', or 'genetic'")
866
+
867
+ clustering_time = time.time() - clustering_start
868
+
869
+ # 2) route per cluster
870
+ routing_start = time.time()
871
+ routes: List[List[int]] = []
872
+ per_route_dist: List[float] = []
873
+ soft_tw_violations = 0
874
+ per_route_loads: List[float] = []
875
+
876
+ for cl in clusters:
877
+ if len(cl) == 0:
878
+ routes.append([])
879
+ per_route_dist.append(0.0)
880
+ per_route_loads.append(0.0)
881
+ continue
882
+ order = build_route_for_cluster(df, cl, depot)
883
+ routes.append(order)
884
+
885
+ # compute distance with depot as start/end
886
+ pts = [depot] + [(df.loc[i, "x"], df.loc[i, "y"]) for i in order] + [depot]
887
+ dist = total_distance(pts)
888
+ per_route_dist.append(dist)
889
+
890
+ # capacity + soft TW check
891
+ load = float(df.loc[order, "demand"].sum()) if len(order) else 0.0
892
+ per_route_loads.append(load)
893
+
894
+ # simple arrival time simulation (speed distance units per time)
895
+ t = 0.0
896
+ prev = depot
897
+ for i in order:
898
+ cur = (df.loc[i, "x"], df.loc[i, "y"])
899
+ t += euclid(prev, cur) / max(speed, 1e-9)
900
+ tw_s = float(df.loc[i, "tw_start"])
901
+ tw_e = float(df.loc[i, "tw_end"])
902
+ if t < tw_s:
903
+ t = tw_s # wait
904
+ if t > tw_e:
905
+ soft_tw_violations += 1
906
+ t += float(df.loc[i, "service"])
907
+ prev = cur
908
+ # back to depot time is irrelevant for TW in this simple model
909
+
910
+ routing_time = time.time() - routing_start
911
+ total_dist = float(sum(per_route_dist))
912
+
913
+ # Build assignment table
914
+ rows = []
915
+ for v, route in enumerate(routes):
916
+ for seq, idx in enumerate(route, start=1):
917
+ rows.append(
918
+ {
919
+ "vehicle": v + 1,
920
+ "sequence": seq,
921
+ "id": df.loc[idx, "id"],
922
+ "x": float(df.loc[idx, "x"]),
923
+ "y": float(df.loc[idx, "y"]),
924
+ "demand": float(df.loc[idx, "demand"]),
925
+ }
926
+ )
927
+ assign_df = pd.DataFrame(rows).sort_values(["vehicle", "sequence"]).reset_index(drop=True)
928
+
929
+ # Enhanced metrics
930
+ active_routes = [d for d in per_route_dist if d > 0]
931
+ active_loads = [l for l in per_route_loads if l > 0]
932
+
933
+ # Load balancing: coefficient of variation
934
+ load_balance = 0.0
935
+ if len(active_loads) > 1:
936
+ load_std = np.std(active_loads)
937
+ load_mean = np.mean(active_loads)
938
+ load_balance = load_std / load_mean if load_mean > 0 else 0.0
939
+
940
+ # Distance balancing
941
+ dist_balance = 0.0
942
+ if len(active_routes) > 1:
943
+ dist_std = np.std(active_routes)
944
+ dist_mean = np.mean(active_routes)
945
+ dist_balance = dist_std / dist_mean if dist_mean > 0 else 0.0
946
+
947
+ # Capacity utilization
948
+ total_capacity_used = sum(active_loads)
949
+ total_capacity_available = capacity * len(active_loads)
950
+ capacity_utilization = total_capacity_used / total_capacity_available if total_capacity_available > 0 else 0.0
951
+
952
+ total_time = time.time() - start_time
953
+
954
+ # Performance metrics
955
+ performance = {
956
+ "total_execution_time_ms": round(total_time * 1000, 2),
957
+ "validation_time_ms": round(validation_time * 1000, 2),
958
+ "clustering_time_ms": round(clustering_time * 1000, 2),
959
+ "routing_time_ms": round(routing_time * 1000, 2),
960
+ "clients_per_second": round(len(df) / total_time, 1) if total_time > 0 else 0,
961
+ "algorithm_complexity": get_algorithm_complexity(algorithm, len(df)),
962
+ }
963
+
964
+ metrics = {
965
+ "algorithm": algorithm,
966
+ "vehicles_used": int(sum(1 for r in routes if len(r) > 0)),
967
+ "vehicles_available": n_vehicles,
968
+ "total_distance": round(total_dist, 3),
969
+ "avg_distance_per_vehicle": round(np.mean(active_routes), 3) if active_routes else 0.0,
970
+ "max_distance": round(max(active_routes), 3) if active_routes else 0.0,
971
+ "min_distance": round(min(active_routes), 3) if active_routes else 0.0,
972
+ "distance_balance_cv": round(dist_balance, 3),
973
+ "per_route_distance": [round(d, 3) for d in per_route_dist],
974
+ "per_route_load": [round(l, 3) for l in per_route_loads],
975
+ "avg_load_per_vehicle": round(np.mean(active_loads), 3) if active_loads else 0.0,
976
+ "load_balance_cv": round(load_balance, 3),
977
+ "capacity_utilization": round(capacity_utilization * 100, 1),
978
+ "capacity": capacity,
979
+ "soft_time_window_violations": int(soft_tw_violations),
980
+ "total_clients": len(df),
981
+ "solution_quality_score": calculate_solution_quality_score(total_dist, dist_balance, load_balance, capacity_utilization),
982
+ "note": f"Heuristic solution ({algorithm} → greedy → 2-opt). TW are soft. Lower CV = better balance.",
983
+ }
984
+
985
+ return {
986
+ "routes": routes,
987
+ "total_distance": total_dist,
988
+ "per_route_distance": per_route_dist,
989
+ "assignments_table": assign_df,
990
+ "metrics": metrics,
991
+ "performance": performance,
992
+ }
993
+
994
+
995
+ # ---------------------------
996
+ # Visualization
997
+ # ---------------------------
998
+
999
+ def plot_solution(
1000
+ df: pd.DataFrame,
1001
+ sol: Dict,
1002
+ depot: Tuple[float, float] = (0.0, 0.0),
1003
+ ):
1004
+ routes = sol["routes"]
1005
+
1006
+ fig, ax = plt.subplots(figsize=(7.5, 6.5))
1007
+ ax.scatter([depot[0]], [depot[1]], s=120, marker="s", label="Depot", zorder=5)
1008
+
1009
+ # color cycle
1010
+ colors = plt.rcParams["axes.prop_cycle"].by_key().get(
1011
+ "color", ["C0","C1","C2","C3","C4","C5"]
1012
+ )
1013
+
1014
+ for v, route in enumerate(routes):
1015
+ if not route:
1016
+ continue
1017
+ c = colors[v % len(colors)]
1018
+ xs = [depot[0]] + [float(df.loc[i, "x"]) for i in route] + [depot[0]]
1019
+ ys = [depot[1]] + [float(df.loc[i, "y"]) for i in route] + [depot[1]]
1020
+ ax.plot(xs, ys, "-", lw=2, color=c, alpha=0.9, label=f"Vehicle {v+1}")
1021
+ ax.scatter(xs[1:-1], ys[1:-1], s=36, color=c, zorder=4)
1022
+
1023
+ # label sequence numbers lightly
1024
+ for k, idx in enumerate(route, start=1):
1025
+ ax.text(
1026
+ float(df.loc[idx, "x"]),
1027
+ float(df.loc[idx, "y"]),
1028
+ str(k),
1029
+ fontsize=8,
1030
+ ha="center",
1031
+ va="center",
1032
+ color="white",
1033
+ bbox=dict(boxstyle="circle,pad=0.2", fc=c, ec="none", alpha=0.7),
1034
+ )
1035
+
1036
+ ax.set_title("Ride-Sharing / CVRP Routes (Heuristic)")
1037
+ ax.set_xlabel("X")
1038
+ ax.set_ylabel("Y")
1039
+ ax.grid(True, alpha=0.25)
1040
+ ax.legend(loc="best", fontsize=8, framealpha=0.9)
1041
+ ax.set_aspect("equal", adjustable="box")
1042
+
1043
+ # ✅ Convert Matplotlib figure → PIL.Image for Gradio
1044
+ buf = io.BytesIO()
1045
+ fig.savefig(buf, format="png", bbox_inches="tight")
1046
+ plt.close(fig)
1047
+ buf.seek(0)
1048
+ return Image.open(buf)
1049
+