Spaces:
Sleeping
Sleeping
Upload 5 files
Browse files- Dockerfile +31 -0
- README.md +157 -10
- app.py +346 -0
- requirements.txt +8 -0
- 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
| 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 |
+
[](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 |
+
|