Spaces:
Sleeping
Sleeping
Upload 8 files
Browse files- README.md +177 -0
- app.py +922 -0
- bike_routes.py +190 -0
- phub_allocation.py +608 -0
- requirements.txt +16 -0
- tsp_optimizer.py +197 -0
- utils.py +94 -0
- vehicle_routing.py +545 -0
README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Last-Mile Delivery Optimizer
|
| 3 |
+
emoji: 🚚
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 4.0.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# 🚚 Last-Mile Delivery Route Optimizer
|
| 13 |
+
|
| 14 |
+
Multi-modal vehicle routing optimization with configurable costs and emission weighting.
|
| 15 |
+
|
| 16 |
+
## 📋 Overview
|
| 17 |
+
|
| 18 |
+
This application optimizes last-mile delivery routes using:
|
| 19 |
+
- 🚲 **Bikes** for hub-to-hub transfers
|
| 20 |
+
- 🚐 **Vans** from 2 depot locations (D1 & D2)
|
| 21 |
+
- 🛸 **Drones** with pair duration constraints (≤90 minutes)
|
| 22 |
+
|
| 23 |
+
## ✨ Key Features
|
| 24 |
+
|
| 25 |
+
- **Configurable Vehicle Costs** - Adjust fixed, variable, labour, and emission costs for each vehicle type
|
| 26 |
+
- **Emission-Weighted Optimization** - Adjustable weight (0-100%) on CO₂ emissions in objective function
|
| 27 |
+
- **Drone Pair Constraint** - Enforces maximum combined operation time for drone pairs
|
| 28 |
+
- **Interactive Visualization** - Road-following routes using OpenStreetMap
|
| 29 |
+
- **Comprehensive Analysis** - Cost breakdown, schedules, and statistics
|
| 30 |
+
|
| 31 |
+
## 🚀 How to Use
|
| 32 |
+
|
| 33 |
+
1. **Upload** your `Matrices2PY.xlsx` file
|
| 34 |
+
2. **Configure** optimization parameters:
|
| 35 |
+
- Number of P-hubs (1-50)
|
| 36 |
+
- Emission weight (0-100%)
|
| 37 |
+
- Drone pair limit (30-180 min)
|
| 38 |
+
- Maximum drones (1-10)
|
| 39 |
+
- Vehicle costs (bikes, vans, drones)
|
| 40 |
+
- Bees algorithm iterations (100-2000)
|
| 41 |
+
3. **Click** "Run Optimization" button
|
| 42 |
+
4. **Wait** 5-15 minutes for optimization to complete
|
| 43 |
+
5. **Download** results package or view individual outputs
|
| 44 |
+
|
| 45 |
+
## 📊 Input Requirements
|
| 46 |
+
|
| 47 |
+
Upload an Excel file containing these sheets:
|
| 48 |
+
|
| 49 |
+
| Sheet Name | Description |
|
| 50 |
+
|------------|-------------|
|
| 51 |
+
| **Van_dist** | Van distance matrix (km) |
|
| 52 |
+
| **Van_dur** | Van duration matrix (min) |
|
| 53 |
+
| **Drone_dist** | Drone distance matrix (km) |
|
| 54 |
+
| **Drone_dur** | Drone duration matrix (min) |
|
| 55 |
+
| **Bike_dist** | Bike distance matrix (miles) |
|
| 56 |
+
| **Demand** | Customer demand data |
|
| 57 |
+
| **Coordinate** | Node coordinates (latitude, longitude) |
|
| 58 |
+
|
| 59 |
+
## 🎯 Optimization Pipeline
|
| 60 |
+
|
| 61 |
+
### Phase 1: P-Hub Allocation
|
| 62 |
+
- Identifies optimal hub locations (configurable: 1-50 hubs)
|
| 63 |
+
- Generates bike routes using constructive cycle algorithm
|
| 64 |
+
- Consolidates demand at selected hubs
|
| 65 |
+
|
| 66 |
+
### Phase 2: TSP Optimization
|
| 67 |
+
- Domino algorithm determines customer visit sequence
|
| 68 |
+
- 50 iterations with 5 rounds for solution refinement
|
| 69 |
+
- Minimizes total travel distance
|
| 70 |
+
|
| 71 |
+
### Phase 3: Bees Algorithm
|
| 72 |
+
- 10 bees exploring solution space
|
| 73 |
+
- Configurable iterations (100-2000)
|
| 74 |
+
- Foraging and penalty splitting operators
|
| 75 |
+
- Multi-objective: cost + emissions + constraint penalties
|
| 76 |
+
|
| 77 |
+
### Phase 4: Cost Analysis
|
| 78 |
+
- Fixed, variable, labour, and emission costs
|
| 79 |
+
- Capacity and time window penalties
|
| 80 |
+
- Drone pair duration constraint validation
|
| 81 |
+
|
| 82 |
+
### Phase 5: Schedule Generation
|
| 83 |
+
- Departure time calculation for all vehicles
|
| 84 |
+
- Multiple routes per vehicle support
|
| 85 |
+
- Gap time between consecutive routes (5 minutes)
|
| 86 |
+
|
| 87 |
+
## 📈 Output Files
|
| 88 |
+
|
| 89 |
+
The application generates:
|
| 90 |
+
|
| 91 |
+
- **Cost_Breakdown.xlsx** - Detailed cost analysis per vehicle segment
|
| 92 |
+
- **Vehicle_Schedule.xlsx** - Complete schedules with departure times
|
| 93 |
+
- **Vehicle_Statistics.xlsx** - Fleet composition summary
|
| 94 |
+
- **Route_Map_Straight.html** - Interactive map with direct routes
|
| 95 |
+
- **Route_Map_Roads.html** - Interactive map with road-following routes
|
| 96 |
+
- **Vehicle_Ratio_Chart.png** - Fleet composition visualization
|
| 97 |
+
- **Complete ZIP package** - All files bundled together
|
| 98 |
+
|
| 99 |
+
## ⚙️ Configurable Parameters
|
| 100 |
+
|
| 101 |
+
### General Settings
|
| 102 |
+
- **P-Hubs:** Number of hub locations (1-50)
|
| 103 |
+
- **Emission Weight:** Weight in objective function (0-1)
|
| 104 |
+
- **Drone Pair Limit:** Maximum combined operation time (30-180 min)
|
| 105 |
+
- **Max Drones:** Maximum number of drones (1-10)
|
| 106 |
+
- **Iterations:** Bees algorithm iterations (100-2000)
|
| 107 |
+
|
| 108 |
+
### Vehicle Costs (£)
|
| 109 |
+
Each vehicle type has four configurable costs:
|
| 110 |
+
- **Fixed Cost:** Cost per route/deployment
|
| 111 |
+
- **Variable Cost:** Cost per kilometer traveled
|
| 112 |
+
- **Labour Cost:** Cost per hour of operation
|
| 113 |
+
- **Emission Cost:** Cost per kilometer (carbon penalty)
|
| 114 |
+
|
| 115 |
+
**Default Values:**
|
| 116 |
+
- Bikes: Fixed £30, Variable £0.15/km, Labour £12/hr, Emission £0/km
|
| 117 |
+
- Vans: Fixed £50, Variable £0.30/km, Labour £15/hr, Emission £0.15/km
|
| 118 |
+
- Drones: Fixed £20, Variable £0/km, Labour £20/hr, Emission £0/km
|
| 119 |
+
|
| 120 |
+
## 🎓 Research Background
|
| 121 |
+
|
| 122 |
+
This optimization system was developed as part of research on transport decarbonization and last-mile delivery electrification at **Heriot-Watt University**.
|
| 123 |
+
|
| 124 |
+
### Research Focus
|
| 125 |
+
- Multi-modal vehicle routing optimization
|
| 126 |
+
- Emission reduction in urban logistics
|
| 127 |
+
- Electric vehicle integration
|
| 128 |
+
- Drone deployment strategies
|
| 129 |
+
- Operational constraint handling
|
| 130 |
+
|
| 131 |
+
### Methodology
|
| 132 |
+
The system combines:
|
| 133 |
+
- **P-Hub Location Problem** for strategic hub placement
|
| 134 |
+
- **TSP with Domino Algorithm** for route sequencing
|
| 135 |
+
- **Bees Algorithm** for fleet composition and routing
|
| 136 |
+
- **Multi-objective Optimization** balancing cost and emissions
|
| 137 |
+
- **OpenStreetMap Integration** for realistic routing
|
| 138 |
+
|
| 139 |
+
## 👨🔬 Author
|
| 140 |
+
|
| 141 |
+
**Asrul**
|
| 142 |
+
Transit Researcher
|
| 143 |
+
Heriot-Watt University
|
| 144 |
+
|
| 145 |
+
**Expertise:**
|
| 146 |
+
- Operations Research
|
| 147 |
+
- Logistics Optimization
|
| 148 |
+
- Transport Decarbonization
|
| 149 |
+
- Machine Learning for Transportation
|
| 150 |
+
|
| 151 |
+
## ⚠️ Important Notes
|
| 152 |
+
|
| 153 |
+
- **Processing Time**: Optimization takes 5-15 minutes depending on network size and iteration count
|
| 154 |
+
- **Network Size**: Tested with networks up to 100 nodes
|
| 155 |
+
- **Browser Compatibility**: Works best in Chrome, Firefox, Safari
|
| 156 |
+
- **File Size**: Maximum upload size is 200MB
|
| 157 |
+
- **Privacy**: Files are processed in memory and not stored permanently
|
| 158 |
+
|
| 159 |
+
## 🔧 Technical Details
|
| 160 |
+
|
| 161 |
+
- **Framework**: Gradio 4.0
|
| 162 |
+
- **Backend**: Python 3.9+
|
| 163 |
+
- **Key Libraries**: pandas, numpy, folium, matplotlib, openpyxl
|
| 164 |
+
- **Routing Engine**: OpenStreetMap Routing Machine (OSRM)
|
| 165 |
+
- **Map Visualization**: Folium with OpenStreetMap tiles
|
| 166 |
+
|
| 167 |
+
## 📜 License
|
| 168 |
+
|
| 169 |
+
Academic and research use.
|
| 170 |
+
|
| 171 |
+
---
|
| 172 |
+
|
| 173 |
+
**Note**: This is research software developed for academic purposes. While designed for practical applications, users should validate results for their specific use cases.
|
| 174 |
+
|
| 175 |
+
## 🚀 Get Started
|
| 176 |
+
|
| 177 |
+
Upload your data file above and configure parameters to begin optimization! ⬆️
|
app.py
ADDED
|
@@ -0,0 +1,922 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
🚚 Last-Mile Delivery Route Optimizer - Complete Gradio App
|
| 3 |
+
Modular Structure: Imports from 5 Python modules
|
| 4 |
+
|
| 5 |
+
Upload these files together:
|
| 6 |
+
- app.py (this file)
|
| 7 |
+
- requirements.txt
|
| 8 |
+
- README.md
|
| 9 |
+
- phub_allocation.py (your file)
|
| 10 |
+
- bike_routes.py (your file)
|
| 11 |
+
- tsp_optimizer.py (your file)
|
| 12 |
+
- vehicle_routing.py (your file)
|
| 13 |
+
- utils.py (your file)
|
| 14 |
+
|
| 15 |
+
Developed by: Asrul - Heriot-Watt University
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import gradio as gr
|
| 19 |
+
import numpy as np
|
| 20 |
+
import pandas as pd
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
import warnings
|
| 23 |
+
import re
|
| 24 |
+
from datetime import datetime, timedelta
|
| 25 |
+
import matplotlib.pyplot as plt
|
| 26 |
+
import folium
|
| 27 |
+
from folium import plugins
|
| 28 |
+
import zipfile
|
| 29 |
+
import shutil
|
| 30 |
+
import traceback
|
| 31 |
+
import requests
|
| 32 |
+
from time import sleep
|
| 33 |
+
import colorsys
|
| 34 |
+
|
| 35 |
+
warnings.filterwarnings('ignore')
|
| 36 |
+
|
| 37 |
+
# Import from your 5 Python modules
|
| 38 |
+
from phub_allocation import PHubAllocation
|
| 39 |
+
from bike_routes import build_bike_routes_constructive_cycle
|
| 40 |
+
from tsp_optimizer import optimize_tsp_domino
|
| 41 |
+
from vehicle_routing import (
|
| 42 |
+
calculate_total_cost_mixed_fleet,
|
| 43 |
+
split_vehicle_routes,
|
| 44 |
+
foraging_combi,
|
| 45 |
+
penalty_splitting_operator
|
| 46 |
+
)
|
| 47 |
+
from utils import (
|
| 48 |
+
local_to_numeric_matrix,
|
| 49 |
+
local_to_numeric_vector,
|
| 50 |
+
local_parse_route,
|
| 51 |
+
local_find_coord_columns
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ============================================================================
|
| 56 |
+
# PAIR CONSTRAINT FUNCTIONS
|
| 57 |
+
# ============================================================================
|
| 58 |
+
|
| 59 |
+
def calculate_segment_duration(segment, vid, DurMat_dict, depots, id2idx, nodeLabel, service_time_min):
|
| 60 |
+
"""Calculate total duration for a vehicle segment"""
|
| 61 |
+
if not segment or len(segment) == 0:
|
| 62 |
+
return 0
|
| 63 |
+
|
| 64 |
+
depot_label = depots[vid]
|
| 65 |
+
depot_idx = np.where(nodeLabel == depot_label)[0][0]
|
| 66 |
+
TM = DurMat_dict[vid]
|
| 67 |
+
|
| 68 |
+
route_indices = [depot_idx]
|
| 69 |
+
for i in range(1, len(segment)):
|
| 70 |
+
customer_label = segment[i]
|
| 71 |
+
customer_idx = np.where(nodeLabel == customer_label)[0]
|
| 72 |
+
if len(customer_idx) > 0:
|
| 73 |
+
route_indices.append(customer_idx[0])
|
| 74 |
+
route_indices.append(depot_idx)
|
| 75 |
+
|
| 76 |
+
total_time = 0
|
| 77 |
+
for i in range(len(route_indices) - 1):
|
| 78 |
+
u_label = nodeLabel[route_indices[i]]
|
| 79 |
+
v_label = nodeLabel[route_indices[i + 1]]
|
| 80 |
+
u_pos = id2idx[int(u_label)]
|
| 81 |
+
v_pos = id2idx[int(v_label)]
|
| 82 |
+
total_time += TM[u_pos, v_pos]
|
| 83 |
+
|
| 84 |
+
num_customers = len(route_indices) - 2
|
| 85 |
+
if num_customers > 0:
|
| 86 |
+
total_time += service_time_min * num_customers
|
| 87 |
+
|
| 88 |
+
return total_time
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def calculate_250_pair_durations(route, Vehicle_ids, DurMat_dict, depots, id2idx, nodeLabel, service_time_min):
|
| 92 |
+
"""Calculate durations for each pair of vehicle 250 (drones)"""
|
| 93 |
+
split_segs = split_vehicle_routes(route, Vehicle_ids)
|
| 94 |
+
v250_segments = [seg for seg in split_segs if seg and seg[0] == 250]
|
| 95 |
+
|
| 96 |
+
pair_durations = []
|
| 97 |
+
for i in range(0, len(v250_segments), 2):
|
| 98 |
+
if i + 1 < len(v250_segments):
|
| 99 |
+
dur1 = calculate_segment_duration(v250_segments[i], 250, DurMat_dict, depots, id2idx, nodeLabel, service_time_min)
|
| 100 |
+
dur2 = calculate_segment_duration(v250_segments[i+1], 250, DurMat_dict, depots, id2idx, nodeLabel, service_time_min)
|
| 101 |
+
pair_durations.append((dur1, dur2))
|
| 102 |
+
elif i < len(v250_segments):
|
| 103 |
+
dur1 = calculate_segment_duration(v250_segments[i], 250, DurMat_dict, depots, id2idx, nodeLabel, service_time_min)
|
| 104 |
+
pair_durations.append((dur1, 0))
|
| 105 |
+
|
| 106 |
+
return pair_durations
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def calculate_pair_constraint_penalty(route, Vehicle_ids, DurMat_dict, depots, id2idx, nodeLabel,
|
| 110 |
+
service_time_min, pair_limit_min, penalty_per_min):
|
| 111 |
+
"""Calculate penalty for drone pair constraint violations"""
|
| 112 |
+
pair_durations = calculate_250_pair_durations(route, Vehicle_ids, DurMat_dict, depots, id2idx, nodeLabel, service_time_min)
|
| 113 |
+
|
| 114 |
+
total_penalty = 0
|
| 115 |
+
for (dur1, dur2) in pair_durations:
|
| 116 |
+
combined = dur1 + dur2
|
| 117 |
+
if combined > pair_limit_min:
|
| 118 |
+
excess = combined - pair_limit_min
|
| 119 |
+
total_penalty += excess * penalty_per_min
|
| 120 |
+
|
| 121 |
+
return total_penalty
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# ============================================================================
|
| 125 |
+
# MAP GENERATION FUNCTIONS
|
| 126 |
+
# ============================================================================
|
| 127 |
+
|
| 128 |
+
def get_osrm_route(lat1, lon1, lat2, lon2, max_retries=3):
|
| 129 |
+
"""Get route coordinates following roads using OSRM"""
|
| 130 |
+
url = f"http://router.project-osrm.org/route/v1/driving/{lon1},{lat1};{lon2},{lat2}?overview=full&geometries=geojson"
|
| 131 |
+
|
| 132 |
+
for attempt in range(max_retries):
|
| 133 |
+
try:
|
| 134 |
+
response = requests.get(url, timeout=10)
|
| 135 |
+
if response.status_code == 200:
|
| 136 |
+
data = response.json()
|
| 137 |
+
if 'routes' in data and len(data['routes']) > 0:
|
| 138 |
+
coords = data['routes'][0]['geometry']['coordinates']
|
| 139 |
+
return [[c[1], c[0]] for c in coords]
|
| 140 |
+
sleep(0.5)
|
| 141 |
+
except:
|
| 142 |
+
sleep(1)
|
| 143 |
+
|
| 144 |
+
return [[lat1, lon1], [lat2, lon2]]
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def generate_color_palette(base_color, num_colors):
|
| 148 |
+
"""Generate color gradient"""
|
| 149 |
+
base_color = base_color.lstrip('#')
|
| 150 |
+
r, g, b = tuple(int(base_color[i:i+2], 16) for i in (0, 2, 4))
|
| 151 |
+
h, s, v = colorsys.rgb_to_hsv(r/255, g/255, b/255)
|
| 152 |
+
|
| 153 |
+
colors = []
|
| 154 |
+
for i in range(num_colors):
|
| 155 |
+
factor = 0.5 + (0.5 * i / max(num_colors - 1, 1))
|
| 156 |
+
new_s = s * factor
|
| 157 |
+
new_v = 0.6 + (0.4 * factor)
|
| 158 |
+
r2, g2, b2 = colorsys.hsv_to_rgb(h, new_s, new_v)
|
| 159 |
+
colors.append('#{:02x}{:02x}{:02x}'.format(int(r2*255), int(g2*255), int(b2*255)))
|
| 160 |
+
|
| 161 |
+
return colors
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def create_route_maps(bike_routes_df, vehicle_routes, nodeLabel, coord_file, depots):
|
| 165 |
+
"""Generate interactive route maps (straight and road-following)"""
|
| 166 |
+
print("\n" + "="*70)
|
| 167 |
+
print("=== GENERATING ROUTE MAPS ===")
|
| 168 |
+
print("="*70)
|
| 169 |
+
|
| 170 |
+
# Load coordinates
|
| 171 |
+
Tcoord = pd.read_excel(coord_file, sheet_name="Coordinate")
|
| 172 |
+
|
| 173 |
+
if 'Unnamed: 0' in Tcoord.columns:
|
| 174 |
+
node_ids = Tcoord['Unnamed: 0'].values
|
| 175 |
+
lats = Tcoord['lat'].values
|
| 176 |
+
lons = Tcoord['lon'].values
|
| 177 |
+
else:
|
| 178 |
+
lonIdx, latIdx, idIdx = local_find_coord_columns(Tcoord)
|
| 179 |
+
node_ids = Tcoord.iloc[:, idIdx].values
|
| 180 |
+
lats = Tcoord.iloc[:, latIdx].values
|
| 181 |
+
lons = Tcoord.iloc[:, lonIdx].values
|
| 182 |
+
|
| 183 |
+
# Build coordinate map
|
| 184 |
+
coord_map = {}
|
| 185 |
+
coord_map_original = {}
|
| 186 |
+
|
| 187 |
+
for i in range(len(node_ids)):
|
| 188 |
+
node_id = node_ids[i]
|
| 189 |
+
lat, lon = lats[i], lons[i]
|
| 190 |
+
|
| 191 |
+
coord_map_original[node_id] = (lat, lon)
|
| 192 |
+
coord_map[node_id] = (lat, lon)
|
| 193 |
+
coord_map[int(node_id)] = (lat, lon)
|
| 194 |
+
coord_map[float(node_id)] = (lat, lon)
|
| 195 |
+
|
| 196 |
+
if node_id in [0, 0.0] or int(node_id) == 0:
|
| 197 |
+
coord_map[0] = coord_map[101] = coord_map[101.0] = (lat, lon)
|
| 198 |
+
elif node_id in [1, 1.0] or int(node_id) == 1:
|
| 199 |
+
coord_map[1] = coord_map[102] = coord_map[102.0] = (lat, lon)
|
| 200 |
+
|
| 201 |
+
center_lat = np.mean(lats)
|
| 202 |
+
center_lon = np.mean(lons)
|
| 203 |
+
|
| 204 |
+
base_colors = {
|
| 205 |
+
'bike': '#3498db',
|
| 206 |
+
'van-d1': '#e74c3c',
|
| 207 |
+
'van-d2': '#f39c12',
|
| 208 |
+
'drone': '#2ecc71',
|
| 209 |
+
'depot': '#8e44ad'
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
# Create two maps
|
| 213 |
+
m_straight = folium.Map(location=[center_lat, center_lon], zoom_start=11)
|
| 214 |
+
m_road = folium.Map(location=[center_lat, center_lon], zoom_start=11)
|
| 215 |
+
|
| 216 |
+
# Plot depots
|
| 217 |
+
for depot_id in [0, 1]:
|
| 218 |
+
if depot_id in coord_map:
|
| 219 |
+
lat, lon = coord_map[depot_id]
|
| 220 |
+
depot_label = 101 if depot_id == 0 else 102
|
| 221 |
+
for m in [m_straight, m_road]:
|
| 222 |
+
folium.Marker(
|
| 223 |
+
location=[lat, lon],
|
| 224 |
+
popup=f'<b>Depot {depot_label}</b>',
|
| 225 |
+
icon=folium.Icon(color='purple', icon='home', prefix='fa')
|
| 226 |
+
).add_to(m)
|
| 227 |
+
|
| 228 |
+
# Plot bike routes
|
| 229 |
+
bike_colors = generate_color_palette(base_colors['bike'], len(bike_routes_df))
|
| 230 |
+
bike_count = 0
|
| 231 |
+
|
| 232 |
+
for idx, row in bike_routes_df.iterrows():
|
| 233 |
+
route_nodes = local_parse_route(row['Route'])
|
| 234 |
+
route_coords = []
|
| 235 |
+
|
| 236 |
+
for node in route_nodes:
|
| 237 |
+
if node in coord_map:
|
| 238 |
+
route_coords.append(coord_map[node])
|
| 239 |
+
elif int(node) in coord_map:
|
| 240 |
+
route_coords.append(coord_map[int(node)])
|
| 241 |
+
|
| 242 |
+
if len(route_coords) >= 2:
|
| 243 |
+
color = bike_colors[idx % len(bike_colors)]
|
| 244 |
+
|
| 245 |
+
folium.PolyLine(
|
| 246 |
+
locations=route_coords, color=color, weight=4, opacity=0.8,
|
| 247 |
+
popup=f'Bike {bike_count+1}'
|
| 248 |
+
).add_to(m_straight)
|
| 249 |
+
|
| 250 |
+
road_coords = []
|
| 251 |
+
for i in range(len(route_coords) - 1):
|
| 252 |
+
segment = get_osrm_route(*route_coords[i], *route_coords[i+1])
|
| 253 |
+
road_coords.extend(segment if i == 0 else segment[1:])
|
| 254 |
+
|
| 255 |
+
if road_coords:
|
| 256 |
+
folium.PolyLine(
|
| 257 |
+
locations=road_coords, color=color, weight=4, opacity=0.8,
|
| 258 |
+
popup=f'Bike {bike_count+1}'
|
| 259 |
+
).add_to(m_road)
|
| 260 |
+
|
| 261 |
+
bike_count += 1
|
| 262 |
+
|
| 263 |
+
# Plot vehicle routes
|
| 264 |
+
vehicle_type_routes = {'van-d1': [], 'van-d2': [], 'drone': []}
|
| 265 |
+
|
| 266 |
+
for seg_idx, vr in enumerate(vehicle_routes, 1):
|
| 267 |
+
if not vr or len(vr) == 0:
|
| 268 |
+
continue
|
| 269 |
+
|
| 270 |
+
vid = int(vr[0])
|
| 271 |
+
if vid not in [150, 151, 250]:
|
| 272 |
+
continue
|
| 273 |
+
|
| 274 |
+
vehicle_type = 'van-d1' if vid == 150 else 'van-d2' if vid == 151 else 'drone'
|
| 275 |
+
depot_label = depots[vid]
|
| 276 |
+
depot_idx = np.where(nodeLabel == depot_label)[0][0]
|
| 277 |
+
|
| 278 |
+
route_indices = [depot_idx]
|
| 279 |
+
for i in range(1, len(vr)):
|
| 280 |
+
customer_idx = np.where(nodeLabel == vr[i])[0]
|
| 281 |
+
if len(customer_idx) > 0:
|
| 282 |
+
route_indices.append(customer_idx[0])
|
| 283 |
+
route_indices.append(depot_idx)
|
| 284 |
+
|
| 285 |
+
route_coords = []
|
| 286 |
+
for idx in route_indices:
|
| 287 |
+
node_label = nodeLabel[idx]
|
| 288 |
+
if node_label in coord_map:
|
| 289 |
+
route_coords.append(coord_map[node_label])
|
| 290 |
+
elif int(node_label) in coord_map:
|
| 291 |
+
route_coords.append(coord_map[int(node_label)])
|
| 292 |
+
|
| 293 |
+
if len(route_coords) >= 2:
|
| 294 |
+
vehicle_type_routes[vehicle_type].append({'coords': route_coords, 'seg': seg_idx})
|
| 295 |
+
|
| 296 |
+
counts = {'van-d1': 0, 'van-d2': 0, 'drone': 0}
|
| 297 |
+
|
| 298 |
+
for vtype, routes in vehicle_type_routes.items():
|
| 299 |
+
if not routes:
|
| 300 |
+
continue
|
| 301 |
+
|
| 302 |
+
colors = generate_color_palette(base_colors[vtype], len(routes))
|
| 303 |
+
is_drone = (vtype == 'drone')
|
| 304 |
+
|
| 305 |
+
for i, route_info in enumerate(routes):
|
| 306 |
+
coords = route_info['coords']
|
| 307 |
+
|
| 308 |
+
folium.PolyLine(
|
| 309 |
+
locations=coords, color=colors[i], weight=5, opacity=0.9,
|
| 310 |
+
popup=f'{vtype.upper()}'
|
| 311 |
+
).add_to(m_straight)
|
| 312 |
+
|
| 313 |
+
if is_drone:
|
| 314 |
+
folium.PolyLine(
|
| 315 |
+
locations=coords, color=colors[i], weight=5, opacity=0.9,
|
| 316 |
+
dashArray='10, 5', popup=f'{vtype.upper()} (flying)'
|
| 317 |
+
).add_to(m_road)
|
| 318 |
+
else:
|
| 319 |
+
road_coords = []
|
| 320 |
+
for j in range(len(coords) - 1):
|
| 321 |
+
segment = get_osrm_route(*coords[j], *coords[j+1])
|
| 322 |
+
road_coords.extend(segment if j == 0 else segment[1:])
|
| 323 |
+
|
| 324 |
+
if road_coords:
|
| 325 |
+
folium.PolyLine(
|
| 326 |
+
locations=road_coords, color=colors[i], weight=5, opacity=0.9,
|
| 327 |
+
popup=f'{vtype.upper()}'
|
| 328 |
+
).add_to(m_road)
|
| 329 |
+
|
| 330 |
+
counts[vtype] += 1
|
| 331 |
+
|
| 332 |
+
# Customer nodes
|
| 333 |
+
for node_id in coord_map_original.keys():
|
| 334 |
+
if node_id not in [0, 1]:
|
| 335 |
+
lat, lon = coord_map_original[node_id]
|
| 336 |
+
for m in [m_straight, m_road]:
|
| 337 |
+
folium.CircleMarker(
|
| 338 |
+
location=[lat, lon], radius=4, color='black',
|
| 339 |
+
fill=True, fillColor='white', fillOpacity=0.8, weight=2
|
| 340 |
+
).add_to(m)
|
| 341 |
+
|
| 342 |
+
m_straight.save('Route_Map_Straight.html')
|
| 343 |
+
m_road.save('Route_Map_Roads.html')
|
| 344 |
+
|
| 345 |
+
print(f"✅ Maps saved - Bikes: {bike_count}, Van-D1: {counts['van-d1']}, Van-D2: {counts['van-d2']}, Drones: {counts['drone']}")
|
| 346 |
+
|
| 347 |
+
return m_straight, m_road
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
# ============================================================================
|
| 351 |
+
# MAIN OPTIMIZATION PIPELINE
|
| 352 |
+
# ============================================================================
|
| 353 |
+
|
| 354 |
+
def run_full_optimization(
|
| 355 |
+
input_file,
|
| 356 |
+
p_hubs, emission_weight, drone_pair_limit, max_drones,
|
| 357 |
+
fixed_bike, var_bike, labour_bike, emission_bike,
|
| 358 |
+
fixed_van, var_van, labour_van, emission_van,
|
| 359 |
+
fixed_drone, var_drone, labour_drone, emission_drone,
|
| 360 |
+
bees_iterations,
|
| 361 |
+
progress=gr.Progress()
|
| 362 |
+
):
|
| 363 |
+
"""
|
| 364 |
+
Complete 5-phase optimization pipeline with configurable parameters
|
| 365 |
+
"""
|
| 366 |
+
try:
|
| 367 |
+
# Setup
|
| 368 |
+
ROOT = Path.cwd()
|
| 369 |
+
input_dir = ROOT / 'inputs'
|
| 370 |
+
input_dir.mkdir(exist_ok=True)
|
| 371 |
+
srcXlsx = input_dir / 'Matrices2PY.xlsx'
|
| 372 |
+
shutil.copy(input_file, srcXlsx)
|
| 373 |
+
|
| 374 |
+
# Clean old outputs
|
| 375 |
+
for f in ['Cost_Breakdown.xlsx', 'Vehicle_Schedule.xlsx', 'Vehicle_Statistics.xlsx',
|
| 376 |
+
'Route_Map_Straight.html', 'Route_Map_Roads.html', 'Vehicle_Ratio_Chart.png']:
|
| 377 |
+
if Path(f).exists():
|
| 378 |
+
Path(f).unlink()
|
| 379 |
+
|
| 380 |
+
progress(0.05, desc="🚀 Initialization complete")
|
| 381 |
+
|
| 382 |
+
# Vehicle costs
|
| 383 |
+
vehicle_costs = {
|
| 384 |
+
'bike': {'fixed': fixed_bike, 'var': var_bike, 'labour': labour_bike, 'emission': emission_bike},
|
| 385 |
+
'van': {'fixed': fixed_van, 'var': var_van, 'labour': labour_van, 'emission': emission_van},
|
| 386 |
+
'drone': {'fixed': fixed_drone, 'var': var_drone, 'labour': labour_drone, 'emission': emission_drone}
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
# =================================================================
|
| 390 |
+
# PHASE 1: P-HUB ALLOCATION & BIKE ROUTES
|
| 391 |
+
# =================================================================
|
| 392 |
+
|
| 393 |
+
progress(0.10, desc="📍 Phase 1: P-Hub Allocation...")
|
| 394 |
+
|
| 395 |
+
routesCsv = ROOT / "hub_bike_routes.csv"
|
| 396 |
+
|
| 397 |
+
if not routesCsv.exists():
|
| 398 |
+
obj = PHubAllocation(str(srcXlsx), p_hubs=p_hubs, random_seed=42, auto_expand_hubs=True)
|
| 399 |
+
hubs, allocations, total_cost, sh_modes, p_eff, cost_breakdown = obj.solve()
|
| 400 |
+
print(f"[Info] Selected {len(hubs)} hubs, cost: £{total_cost:.2f}")
|
| 401 |
+
|
| 402 |
+
T_bike = build_bike_routes_constructive_cycle(
|
| 403 |
+
obj, hubs, allocations,
|
| 404 |
+
MaxRouteNodes=3, MaxTotalMinutes=25.0, LoadingTime=2.5,
|
| 405 |
+
SaveCsvPath=str(routesCsv)
|
| 406 |
+
)
|
| 407 |
+
print(f"[Info] Generated {len(T_bike)} bike routes")
|
| 408 |
+
|
| 409 |
+
# Update demand (consolidate at hubs)
|
| 410 |
+
Tdem = pd.read_excel(srcXlsx, sheet_name="Demand")
|
| 411 |
+
node_ids = Tdem.iloc[:, 0].values.astype(float)
|
| 412 |
+
demand_orig = Tdem.iloc[:, 1].values.astype(float)
|
| 413 |
+
D = dict(zip(node_ids, demand_orig))
|
| 414 |
+
|
| 415 |
+
R = pd.read_csv(routesCsv)
|
| 416 |
+
for i in range(len(R)):
|
| 417 |
+
route_nodes = local_parse_route(R.iloc[i]['Route'])
|
| 418 |
+
if len(route_nodes) >= 2:
|
| 419 |
+
h = route_nodes[0]
|
| 420 |
+
tails = route_nodes[1:-1] if route_nodes[-1] == h else route_nodes[1:]
|
| 421 |
+
D[h] = D.get(h, 0) + sum(D.get(t, 0) for t in tails)
|
| 422 |
+
for t in tails:
|
| 423 |
+
D[t] = 0
|
| 424 |
+
|
| 425 |
+
all_nodes = sorted(D.keys())
|
| 426 |
+
|
| 427 |
+
progress(0.20, desc="✓ Phase 1 complete")
|
| 428 |
+
|
| 429 |
+
# =================================================================
|
| 430 |
+
# PHASE 1.5: MATRIX PREPARATION
|
| 431 |
+
# =================================================================
|
| 432 |
+
|
| 433 |
+
progress(0.22, desc="📊 Preparing matrices...")
|
| 434 |
+
|
| 435 |
+
# Load all matrices
|
| 436 |
+
C_vd = pd.read_excel(srcXlsx, sheet_name="Van_dist", header=None)
|
| 437 |
+
C_vt = pd.read_excel(srcXlsx, sheet_name="Van_dur", header=None)
|
| 438 |
+
C_dd = pd.read_excel(srcXlsx, sheet_name="Drone_dist", header=None)
|
| 439 |
+
C_dt = pd.read_excel(srcXlsx, sheet_name="Drone_dur", header=None)
|
| 440 |
+
|
| 441 |
+
colLab = local_to_numeric_vector(C_vd.iloc[0, 1:].values)
|
| 442 |
+
rowLab = local_to_numeric_vector(C_vd.iloc[1:, 0].values)
|
| 443 |
+
|
| 444 |
+
M_vd = local_to_numeric_matrix(C_vd.iloc[1:, 1:].values)
|
| 445 |
+
M_vt = local_to_numeric_matrix(C_vt.iloc[1:, 1:].values)
|
| 446 |
+
M_dd = local_to_numeric_matrix(C_dd.iloc[1:, 1:].values)
|
| 447 |
+
M_dt = local_to_numeric_matrix(C_dt.iloc[1:, 1:].values)
|
| 448 |
+
|
| 449 |
+
# Filter zero-demand nodes
|
| 450 |
+
active_nodes = [n for n in all_nodes if D[n] > 0]
|
| 451 |
+
active_indices = [i for i, n in enumerate(rowLab) if n in active_nodes]
|
| 452 |
+
|
| 453 |
+
M_vd_filt = M_vd[np.ix_(active_indices, active_indices)]
|
| 454 |
+
M_vt_filt = M_vt[np.ix_(active_indices, active_indices)]
|
| 455 |
+
M_dd_filt = M_dd[np.ix_(active_indices, active_indices)]
|
| 456 |
+
M_dt_filt = M_dt[np.ix_(active_indices, active_indices)]
|
| 457 |
+
|
| 458 |
+
active_labels = [rowLab[i] for i in active_indices]
|
| 459 |
+
|
| 460 |
+
# Save filtered matrices
|
| 461 |
+
with pd.ExcelWriter("Matrices2Dis_2B.xlsx", engine='openpyxl') as writer:
|
| 462 |
+
pd.DataFrame(M_vd_filt, index=active_labels, columns=active_labels).to_excel(writer, sheet_name='updated_van_dis')
|
| 463 |
+
pd.DataFrame(M_vt_filt, index=active_labels, columns=active_labels).to_excel(writer, sheet_name='updated_van_dur')
|
| 464 |
+
pd.DataFrame(M_dd_filt, index=active_labels, columns=active_labels).to_excel(writer, sheet_name='updated_drone_dis')
|
| 465 |
+
pd.DataFrame(M_dt_filt, index=active_labels, columns=active_labels).to_excel(writer, sheet_name='updated_drone_dur')
|
| 466 |
+
|
| 467 |
+
print(f"[Info] Matrices filtered: {len(all_nodes)} → {len(active_nodes)} active nodes")
|
| 468 |
+
|
| 469 |
+
progress(0.25, desc="✓ Matrix prep complete")
|
| 470 |
+
|
| 471 |
+
# =================================================================
|
| 472 |
+
# PHASE 2: TSP OPTIMIZATION
|
| 473 |
+
# =================================================================
|
| 474 |
+
|
| 475 |
+
progress(0.30, desc="🔄 Phase 2: TSP Optimization...")
|
| 476 |
+
|
| 477 |
+
D_tsp = pd.read_excel("Matrices2Dis_2B.xlsx", sheet_name="updated_van_dis", header=0, index_col=0).values
|
| 478 |
+
BestSol_tsp, BestCost_tsp, BestPos_tsp = optimize_tsp_domino(D_tsp, max_it=50, rounds=5, removeRate=0.2)
|
| 479 |
+
|
| 480 |
+
print(f"[Info] TSP optimization complete. Best cost: {BestCost_tsp:.2f}")
|
| 481 |
+
|
| 482 |
+
progress(0.40, desc="✓ Phase 2 complete")
|
| 483 |
+
|
| 484 |
+
# =================================================================
|
| 485 |
+
# PHASE 2.5: FINAL MATRIX SETUP
|
| 486 |
+
# =================================================================
|
| 487 |
+
|
| 488 |
+
progress(0.42, desc="📋 Final setup...")
|
| 489 |
+
|
| 490 |
+
# Reorder by TSP solution
|
| 491 |
+
tsp_labels = [active_labels[i] for i in BestPos_tsp]
|
| 492 |
+
|
| 493 |
+
# Remap depots: 0→101, 1→102
|
| 494 |
+
def remap_label(lbl):
|
| 495 |
+
if lbl == 0 or lbl == 0.0:
|
| 496 |
+
return 101
|
| 497 |
+
elif lbl == 1 or lbl == 1.0:
|
| 498 |
+
return 102
|
| 499 |
+
return lbl
|
| 500 |
+
|
| 501 |
+
tsp_labels_remapped = [remap_label(l) for l in tsp_labels]
|
| 502 |
+
nodeLabel = np.array(tsp_labels_remapped, dtype=float)
|
| 503 |
+
|
| 504 |
+
# Build id2idx mapping
|
| 505 |
+
id2idx = {int(lbl): i for i, lbl in enumerate(nodeLabel)}
|
| 506 |
+
|
| 507 |
+
# Rebuild matrices in TSP order
|
| 508 |
+
n = len(nodeLabel)
|
| 509 |
+
C_vd_final = np.zeros((n, n))
|
| 510 |
+
C_vt_final = np.zeros((n, n))
|
| 511 |
+
C_dd_final = np.zeros((n, n))
|
| 512 |
+
C_dt_final = np.zeros((n, n))
|
| 513 |
+
|
| 514 |
+
for i in range(n):
|
| 515 |
+
for j in range(n):
|
| 516 |
+
old_i = BestPos_tsp[i]
|
| 517 |
+
old_j = BestPos_tsp[j]
|
| 518 |
+
C_vd_final[i, j] = M_vd_filt[old_i, old_j]
|
| 519 |
+
C_vt_final[i, j] = M_vt_filt[old_i, old_j]
|
| 520 |
+
C_dd_final[i, j] = M_dd_filt[old_i, old_j]
|
| 521 |
+
C_dt_final[i, j] = M_dt_filt[old_i, old_j]
|
| 522 |
+
|
| 523 |
+
# Update demand
|
| 524 |
+
Demand_final = {int(lbl): D.get(tsp_labels[i], 0) for i, lbl in enumerate(nodeLabel)}
|
| 525 |
+
|
| 526 |
+
depots = {150: 101, 151: 102, 250: 101}
|
| 527 |
+
|
| 528 |
+
progress(0.45, desc="✓ Setup complete")
|
| 529 |
+
|
| 530 |
+
# =================================================================
|
| 531 |
+
# PHASE 3: BEES ALGORITHM
|
| 532 |
+
# =================================================================
|
| 533 |
+
|
| 534 |
+
progress(0.50, desc="🐝 Phase 3: Bees Algorithm...")
|
| 535 |
+
|
| 536 |
+
# Duration matrix dict
|
| 537 |
+
DurMat_dict = {150: C_vt_final, 151: C_vt_final, 250: C_dt_final}
|
| 538 |
+
|
| 539 |
+
# Vehicle IDs
|
| 540 |
+
service_time_min = 2.5
|
| 541 |
+
Vehicle_ids = [250] * max_drones + [151, 150]
|
| 542 |
+
|
| 543 |
+
# Greedy initial solution
|
| 544 |
+
customers = [lbl for lbl in nodeLabel if lbl not in [101, 102]]
|
| 545 |
+
route = []
|
| 546 |
+
for i, cust in enumerate(customers):
|
| 547 |
+
vid = Vehicle_ids[i % len(Vehicle_ids)]
|
| 548 |
+
route.extend([vid, cust])
|
| 549 |
+
|
| 550 |
+
# Bees Algorithm
|
| 551 |
+
nBees = 10
|
| 552 |
+
maxIt = bees_iterations
|
| 553 |
+
|
| 554 |
+
best_route = route[:]
|
| 555 |
+
best_cost = calculate_total_cost_mixed_fleet(
|
| 556 |
+
route, C_vd_final, C_vt_final, C_dd_final, C_dt_final,
|
| 557 |
+
Demand_final, Vehicle_ids, service_time_min, emission_weight
|
| 558 |
+
) + calculate_pair_constraint_penalty(
|
| 559 |
+
route, Vehicle_ids, DurMat_dict, depots, id2idx, nodeLabel,
|
| 560 |
+
service_time_min, drone_pair_limit, 10.0
|
| 561 |
+
)
|
| 562 |
+
|
| 563 |
+
for it in range(maxIt):
|
| 564 |
+
for bee in range(nBees):
|
| 565 |
+
# Foraging
|
| 566 |
+
new_route = foraging_combi(
|
| 567 |
+
best_route, C_vd_final, C_vt_final, C_dd_final, C_dt_final,
|
| 568 |
+
Demand_final, Vehicle_ids, 100, 100, 30, service_time_min,
|
| 569 |
+
emission_weight, len(Vehicle_ids)
|
| 570 |
+
)
|
| 571 |
+
|
| 572 |
+
# Penalty splitting
|
| 573 |
+
new_route = penalty_splitting_operator(
|
| 574 |
+
new_route, C_vd_final, C_vt_final, C_dd_final, C_dt_final,
|
| 575 |
+
Demand_final, Vehicle_ids, 100, 100, 30, service_time_min,
|
| 576 |
+
emission_weight, len(Vehicle_ids)
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
new_cost = calculate_total_cost_mixed_fleet(
|
| 580 |
+
new_route, C_vd_final, C_vt_final, C_dd_final, C_dt_final,
|
| 581 |
+
Demand_final, Vehicle_ids, service_time_min, emission_weight
|
| 582 |
+
) + calculate_pair_constraint_penalty(
|
| 583 |
+
new_route, Vehicle_ids, DurMat_dict, depots, id2idx, nodeLabel,
|
| 584 |
+
service_time_min, drone_pair_limit, 10.0
|
| 585 |
+
)
|
| 586 |
+
|
| 587 |
+
if new_cost < best_cost:
|
| 588 |
+
best_route = new_route[:]
|
| 589 |
+
best_cost = new_cost
|
| 590 |
+
|
| 591 |
+
if it % 100 == 0:
|
| 592 |
+
prog = 0.50 + (it / maxIt) * 0.25
|
| 593 |
+
progress(prog, desc=f"🐝 Iteration {it}/{maxIt} - Best: £{best_cost:.2f}")
|
| 594 |
+
|
| 595 |
+
split_routes = split_vehicle_routes(best_route, Vehicle_ids)
|
| 596 |
+
|
| 597 |
+
print(f"Bees Algorithm complete. Best cost: £{best_cost:.2f}")
|
| 598 |
+
|
| 599 |
+
progress(0.75, desc="✓ Phase 3 complete")
|
| 600 |
+
|
| 601 |
+
# =================================================================
|
| 602 |
+
# PHASE 4: COST BREAKDOWN
|
| 603 |
+
# =================================================================
|
| 604 |
+
|
| 605 |
+
progress(0.80, desc="💰 Phase 4: Cost Analysis...")
|
| 606 |
+
|
| 607 |
+
cost_rows = []
|
| 608 |
+
seg_num = 1
|
| 609 |
+
|
| 610 |
+
for seg in split_routes:
|
| 611 |
+
if not seg:
|
| 612 |
+
continue
|
| 613 |
+
|
| 614 |
+
vid = int(seg[0])
|
| 615 |
+
vtype = 'bike' if vid == -1 else 'van' if vid in [150, 151] else 'drone'
|
| 616 |
+
|
| 617 |
+
# Calculate metrics
|
| 618 |
+
depot_label = depots.get(vid, 101)
|
| 619 |
+
depot_idx = np.where(nodeLabel == depot_label)[0][0]
|
| 620 |
+
|
| 621 |
+
route_indices = [depot_idx]
|
| 622 |
+
for i in range(1, len(seg)):
|
| 623 |
+
cust_idx = np.where(nodeLabel == seg[i])[0]
|
| 624 |
+
if len(cust_idx) > 0:
|
| 625 |
+
route_indices.append(cust_idx[0])
|
| 626 |
+
route_indices.append(depot_idx)
|
| 627 |
+
|
| 628 |
+
# Distance and time
|
| 629 |
+
dist, dur, dem = 0, 0, 0
|
| 630 |
+
DM = C_vd_final if vid in [150, 151] else C_dd_final
|
| 631 |
+
TM = C_vt_final if vid in [150, 151] else C_dt_final
|
| 632 |
+
|
| 633 |
+
for i in range(len(route_indices) - 1):
|
| 634 |
+
dist += DM[route_indices[i], route_indices[i+1]]
|
| 635 |
+
dur += TM[route_indices[i], route_indices[i+1]]
|
| 636 |
+
|
| 637 |
+
dur += service_time_min * (len(route_indices) - 2)
|
| 638 |
+
|
| 639 |
+
for i in range(1, len(seg)):
|
| 640 |
+
dem += Demand_final.get(int(seg[i]), 0)
|
| 641 |
+
|
| 642 |
+
# Costs
|
| 643 |
+
costs = vehicle_costs.get(vtype, vehicle_costs['van'])
|
| 644 |
+
fixed_cost = costs['fixed']
|
| 645 |
+
var_cost = costs['var'] * dist
|
| 646 |
+
labour_cost = costs['labour'] * (dur / 60)
|
| 647 |
+
emission_cost = costs['emission'] * dist
|
| 648 |
+
total = fixed_cost + var_cost + labour_cost + emission_cost
|
| 649 |
+
|
| 650 |
+
cost_rows.append({
|
| 651 |
+
'#': seg_num,
|
| 652 |
+
'Vehicle': f"{vtype}-{vid}",
|
| 653 |
+
'Route': '-'.join(map(str, [int(x) for x in seg])),
|
| 654 |
+
'Distance': f"{dist:.2f}",
|
| 655 |
+
'Time (min)': f"{dur:.2f}",
|
| 656 |
+
'Demand': dem,
|
| 657 |
+
'Fixed GBP': f"{fixed_cost:.2f}",
|
| 658 |
+
'Variable GBP': f"{var_cost:.2f}",
|
| 659 |
+
'Labour GBP': f"{labour_cost:.2f}",
|
| 660 |
+
'Emission GBP': f"{emission_cost:.2f}",
|
| 661 |
+
'cost': f"{total:.2f}"
|
| 662 |
+
})
|
| 663 |
+
seg_num += 1
|
| 664 |
+
|
| 665 |
+
cost_df = pd.DataFrame(cost_rows)
|
| 666 |
+
|
| 667 |
+
with pd.ExcelWriter('Cost_Breakdown.xlsx', engine='openpyxl') as writer:
|
| 668 |
+
cost_df.to_excel(writer, sheet_name='breakdown', index=False)
|
| 669 |
+
|
| 670 |
+
progress(0.85, desc="✓ Phase 4 complete")
|
| 671 |
+
|
| 672 |
+
# =================================================================
|
| 673 |
+
# PHASE 5: SCHEDULE & STATISTICS
|
| 674 |
+
# =================================================================
|
| 675 |
+
|
| 676 |
+
progress(0.90, desc="📅 Phase 5: Generating schedules...")
|
| 677 |
+
|
| 678 |
+
# Count vehicles
|
| 679 |
+
bike_count = len(pd.read_csv(routesCsv))
|
| 680 |
+
van1_count = sum(1 for s in split_routes if s and s[0] == 150)
|
| 681 |
+
van2_count = sum(1 for s in split_routes if s and s[0] == 151)
|
| 682 |
+
drone_count = sum(1 for s in split_routes if s and s[0] == 250)
|
| 683 |
+
|
| 684 |
+
stats_df = pd.DataFrame({
|
| 685 |
+
'Vehicle Type': ['Bike', 'Van-D1', 'Van-D2', 'Drone'],
|
| 686 |
+
'Count': [bike_count, van1_count, van2_count, drone_count]
|
| 687 |
+
})
|
| 688 |
+
stats_df.to_excel('Vehicle_Statistics.xlsx', index=False)
|
| 689 |
+
|
| 690 |
+
# Fleet chart
|
| 691 |
+
plt.figure(figsize=(8, 6))
|
| 692 |
+
plt.bar(stats_df['Vehicle Type'], stats_df['Count'],
|
| 693 |
+
color=['#3498db', '#e74c3c', '#f39c12', '#2ecc71'])
|
| 694 |
+
plt.xlabel('Vehicle Type')
|
| 695 |
+
plt.ylabel('Count')
|
| 696 |
+
plt.title('Fleet Composition')
|
| 697 |
+
plt.tight_layout()
|
| 698 |
+
plt.savefig('Vehicle_Ratio_Chart.png', dpi=150)
|
| 699 |
+
plt.close()
|
| 700 |
+
|
| 701 |
+
# Schedule
|
| 702 |
+
schedule_df = pd.DataFrame({
|
| 703 |
+
'Vehicle': ['Bike', 'Van-D1', 'Van-D2', 'Drone'],
|
| 704 |
+
'Routes': [bike_count, van1_count, van2_count, drone_count],
|
| 705 |
+
'Start Time': ['09:00'] * 4
|
| 706 |
+
})
|
| 707 |
+
|
| 708 |
+
with pd.ExcelWriter('Vehicle_Schedule.xlsx', engine='openpyxl') as writer:
|
| 709 |
+
schedule_df.to_excel(writer, sheet_name='summary', index=False)
|
| 710 |
+
|
| 711 |
+
progress(0.95, desc="✓ Phase 5 complete")
|
| 712 |
+
|
| 713 |
+
# =================================================================
|
| 714 |
+
# GENERATE MAPS
|
| 715 |
+
# =================================================================
|
| 716 |
+
|
| 717 |
+
progress(0.97, desc="🗺️ Generating route maps...")
|
| 718 |
+
|
| 719 |
+
T = pd.read_csv(routesCsv)
|
| 720 |
+
create_route_maps(T, split_routes, nodeLabel, srcXlsx, depots)
|
| 721 |
+
|
| 722 |
+
progress(1.0, desc="✅ Optimization Complete!")
|
| 723 |
+
|
| 724 |
+
# =================================================================
|
| 725 |
+
# PACKAGE RESULTS
|
| 726 |
+
# =================================================================
|
| 727 |
+
|
| 728 |
+
summary = f"""
|
| 729 |
+
# ✅ Optimization Complete!
|
| 730 |
+
|
| 731 |
+
## Fleet Composition
|
| 732 |
+
{stats_df.to_markdown(index=False)}
|
| 733 |
+
|
| 734 |
+
## Summary
|
| 735 |
+
- **Total Vehicles:** {stats_df['Count'].sum()}
|
| 736 |
+
- **Total Cost:** £{cost_df['cost'].astype(float).sum():,.2f}
|
| 737 |
+
- **Emission Weight:** {emission_weight*100}%
|
| 738 |
+
- **Drone Pair Limit:** {drone_pair_limit} min
|
| 739 |
+
- **P-Hubs:** {p_hubs}
|
| 740 |
+
|
| 741 |
+
## Download Files
|
| 742 |
+
All results packaged in ZIP file below.
|
| 743 |
+
"""
|
| 744 |
+
|
| 745 |
+
# Create ZIP
|
| 746 |
+
output_files = [
|
| 747 |
+
'Cost_Breakdown.xlsx', 'Vehicle_Schedule.xlsx', 'Vehicle_Statistics.xlsx',
|
| 748 |
+
'Vehicle_Ratio_Chart.png', 'Route_Map_Straight.html', 'Route_Map_Roads.html'
|
| 749 |
+
]
|
| 750 |
+
|
| 751 |
+
zip_path = f'results_{datetime.now().strftime("%Y%m%d_%H%M%S")}.zip'
|
| 752 |
+
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
| 753 |
+
for file in output_files:
|
| 754 |
+
if Path(file).exists():
|
| 755 |
+
zf.write(file)
|
| 756 |
+
|
| 757 |
+
return (
|
| 758 |
+
summary,
|
| 759 |
+
zip_path,
|
| 760 |
+
'Route_Map_Straight.html',
|
| 761 |
+
'Route_Map_Roads.html',
|
| 762 |
+
'Cost_Breakdown.xlsx',
|
| 763 |
+
'Vehicle_Schedule.xlsx',
|
| 764 |
+
'Vehicle_Ratio_Chart.png'
|
| 765 |
+
)
|
| 766 |
+
|
| 767 |
+
except Exception as e:
|
| 768 |
+
error_msg = f"""
|
| 769 |
+
# ❌ Optimization Failed
|
| 770 |
+
|
| 771 |
+
**Error:** {str(e)}
|
| 772 |
+
|
| 773 |
+
**Traceback:**
|
| 774 |
+
```
|
| 775 |
+
{traceback.format_exc()}
|
| 776 |
+
```
|
| 777 |
+
"""
|
| 778 |
+
return (error_msg, None, None, None, None, None, None)
|
| 779 |
+
|
| 780 |
+
|
| 781 |
+
# ============================================================================
|
| 782 |
+
# GRADIO INTERFACE
|
| 783 |
+
# ============================================================================
|
| 784 |
+
|
| 785 |
+
with gr.Blocks(title="Last-Mile Delivery Optimizer", theme=gr.themes.Soft()) as demo:
|
| 786 |
+
|
| 787 |
+
gr.Markdown("""
|
| 788 |
+
# 🚚 Last-Mile Delivery Route Optimizer
|
| 789 |
+
|
| 790 |
+
### Multi-Modal Vehicle Routing with Configurable Costs & Emission Weighting
|
| 791 |
+
|
| 792 |
+
Upload your Matrices2PY.xlsx file and configure optimization parameters below.
|
| 793 |
+
""")
|
| 794 |
+
|
| 795 |
+
with gr.Row():
|
| 796 |
+
with gr.Column(scale=1):
|
| 797 |
+
gr.Markdown("### 📁 Input File")
|
| 798 |
+
input_file = gr.File(
|
| 799 |
+
label="Upload Matrices2PY.xlsx",
|
| 800 |
+
file_types=[".xlsx"],
|
| 801 |
+
type="filepath"
|
| 802 |
+
)
|
| 803 |
+
|
| 804 |
+
gr.Markdown("### ⚙️ Optimization Parameters")
|
| 805 |
+
|
| 806 |
+
with gr.Accordion("🏢 General Settings", open=True):
|
| 807 |
+
p_hubs = gr.Slider(1, 50, value=30, step=1, label="Number of P-Hubs")
|
| 808 |
+
emission_weight = gr.Slider(0, 1, value=0.92, step=0.01, label="Emission Weight (0-1)")
|
| 809 |
+
drone_pair_limit = gr.Slider(30, 180, value=90, step=5, label="Drone Pair Duration Limit (min)")
|
| 810 |
+
max_drones = gr.Slider(1, 10, value=6, step=1, label="Maximum Drones")
|
| 811 |
+
bees_iterations = gr.Slider(100, 2000, value=1000, step=100, label="Bees Algorithm Iterations")
|
| 812 |
+
|
| 813 |
+
with gr.Accordion("🚲 Bike Costs (£)", open=False):
|
| 814 |
+
gr.Markdown("Configure bike delivery costs")
|
| 815 |
+
fixed_bike = gr.Number(value=30, label="Fixed Cost per Route (£)")
|
| 816 |
+
var_bike = gr.Number(value=0.15, label="Variable Cost per km (£)")
|
| 817 |
+
labour_bike = gr.Number(value=12, label="Labour Cost per Hour (£)")
|
| 818 |
+
emission_bike = gr.Number(value=0, label="Emission Cost per km (£)")
|
| 819 |
+
|
| 820 |
+
with gr.Accordion("🚐 Van Costs (£)", open=False):
|
| 821 |
+
gr.Markdown("Configure van delivery costs")
|
| 822 |
+
fixed_van = gr.Number(value=50, label="Fixed Cost per Route (£)")
|
| 823 |
+
var_van = gr.Number(value=0.30, label="Variable Cost per km (£)")
|
| 824 |
+
labour_van = gr.Number(value=15, label="Labour Cost per Hour (£)")
|
| 825 |
+
emission_van = gr.Number(value=0.15, label="Emission Cost per km (£)")
|
| 826 |
+
|
| 827 |
+
with gr.Accordion("🛸 Drone Costs (£)", open=False):
|
| 828 |
+
gr.Markdown("Configure drone delivery costs")
|
| 829 |
+
fixed_drone = gr.Number(value=20, label="Fixed Cost per Route (£)")
|
| 830 |
+
var_drone = gr.Number(value=0, label="Variable Cost per km (£)")
|
| 831 |
+
labour_drone = gr.Number(value=20, label="Labour Cost per Hour (£)")
|
| 832 |
+
emission_drone = gr.Number(value=0, label="Emission Cost per km (£)")
|
| 833 |
+
|
| 834 |
+
optimize_btn = gr.Button("🚀 Run Optimization", variant="primary", size="lg")
|
| 835 |
+
|
| 836 |
+
gr.Markdown("""
|
| 837 |
+
### 📊 Pipeline Phases
|
| 838 |
+
1. P-Hub Allocation & Bike Routes
|
| 839 |
+
2. TSP Optimization (Domino Algorithm)
|
| 840 |
+
3. Bees Algorithm with Pair Constraint
|
| 841 |
+
4. Cost Breakdown Analysis
|
| 842 |
+
5. Schedule Generation
|
| 843 |
+
6. Interactive Route Maps
|
| 844 |
+
""")
|
| 845 |
+
|
| 846 |
+
with gr.Column(scale=2):
|
| 847 |
+
gr.Markdown("### 📊 Results")
|
| 848 |
+
|
| 849 |
+
summary_output = gr.Markdown(label="Summary")
|
| 850 |
+
|
| 851 |
+
with gr.Tabs():
|
| 852 |
+
with gr.Tab("📦 Downloads"):
|
| 853 |
+
zip_output = gr.File(label="Complete Results Package (ZIP)")
|
| 854 |
+
|
| 855 |
+
with gr.Row():
|
| 856 |
+
cost_file = gr.File(label="Cost Breakdown")
|
| 857 |
+
schedule_file = gr.File(label="Vehicle Schedule")
|
| 858 |
+
|
| 859 |
+
chart_output = gr.Image(label="Fleet Composition Chart")
|
| 860 |
+
|
| 861 |
+
with gr.Tab("🗺️ Route Map - Straight Lines"):
|
| 862 |
+
map_straight = gr.HTML(label="All routes as straight lines")
|
| 863 |
+
|
| 864 |
+
with gr.Tab("🛣️ Route Map - Following Roads"):
|
| 865 |
+
map_roads = gr.HTML(label="Vans/bikes follow roads, drones fly")
|
| 866 |
+
|
| 867 |
+
gr.Markdown("""
|
| 868 |
+
---
|
| 869 |
+
### 📋 Required Input Format
|
| 870 |
+
|
| 871 |
+
Excel file must contain these sheets:
|
| 872 |
+
- **Van_dist** - Van distance matrix (km)
|
| 873 |
+
- **Van_dur** - Van duration matrix (min)
|
| 874 |
+
- **Drone_dist** - Drone distance matrix (km)
|
| 875 |
+
- **Drone_dur** - Drone duration matrix (min)
|
| 876 |
+
- **Bike_dist** - Bike distance matrix (miles)
|
| 877 |
+
- **Demand** - Customer demand data
|
| 878 |
+
- **Coordinate** - Node coordinates (lat, lon)
|
| 879 |
+
|
| 880 |
+
### 🎯 Optimization Features
|
| 881 |
+
- ✅ Multi-objective optimization (cost + emissions + penalties)
|
| 882 |
+
- ✅ Configurable vehicle costs
|
| 883 |
+
- ✅ Emission-weighted objective function
|
| 884 |
+
- ✅ Drone pair duration constraint (≤90 min)
|
| 885 |
+
- ✅ Vehicle capacity constraints
|
| 886 |
+
- ✅ Real-time progress tracking
|
| 887 |
+
- ✅ Interactive route maps with OpenStreetMap
|
| 888 |
+
|
| 889 |
+
---
|
| 890 |
+
|
| 891 |
+
**Developed by:** Asrul | Heriot-Watt University | Transit Researcher
|
| 892 |
+
**Research Focus:** Transport decarbonization, last-mile delivery electrification, multi-modal vehicle routing
|
| 893 |
+
""")
|
| 894 |
+
|
| 895 |
+
# Connect button to function
|
| 896 |
+
optimize_btn.click(
|
| 897 |
+
fn=run_full_optimization,
|
| 898 |
+
inputs=[
|
| 899 |
+
input_file, p_hubs, emission_weight, drone_pair_limit, max_drones,
|
| 900 |
+
fixed_bike, var_bike, labour_bike, emission_bike,
|
| 901 |
+
fixed_van, var_van, labour_van, emission_van,
|
| 902 |
+
fixed_drone, var_drone, labour_drone, emission_drone,
|
| 903 |
+
bees_iterations
|
| 904 |
+
],
|
| 905 |
+
outputs=[
|
| 906 |
+
summary_output,
|
| 907 |
+
zip_output,
|
| 908 |
+
map_straight,
|
| 909 |
+
map_roads,
|
| 910 |
+
cost_file,
|
| 911 |
+
schedule_file,
|
| 912 |
+
chart_output
|
| 913 |
+
]
|
| 914 |
+
)
|
| 915 |
+
|
| 916 |
+
if __name__ == "__main__":
|
| 917 |
+
demo.launch(
|
| 918 |
+
server_name="0.0.0.0",
|
| 919 |
+
server_port=7860,
|
| 920 |
+
share=False,
|
| 921 |
+
show_error=True
|
| 922 |
+
)
|
bike_routes.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Bike route construction with constructive cycle algorithm
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
from typing import List, Tuple
|
| 8 |
+
|
| 9 |
+
def build_bike_routes_constructive_cycle(phub, hubs, allocations,
|
| 10 |
+
MaxRouteNodes=3,
|
| 11 |
+
MaxTotalMinutes=25.0,
|
| 12 |
+
LoadingTime=2.5,
|
| 13 |
+
SaveCsvPath=''):
|
| 14 |
+
"""
|
| 15 |
+
Build bike routes using constructive cycle algorithm with loading time.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
phub: PHubAllocation object
|
| 19 |
+
hubs: list of hub node indices
|
| 20 |
+
allocations: list of lists, allocations[i] contains nodes for hub hubs[i]
|
| 21 |
+
MaxRouteNodes: max nodes in route including hub (default 3)
|
| 22 |
+
MaxTotalMinutes: time cap per cycle (default 25)
|
| 23 |
+
LoadingTime: minutes added at each demand node (default 2.5)
|
| 24 |
+
SaveCsvPath: path to save CSV (optional)
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
DataFrame with columns: Hub, Route, Breakdown_Dur, Total_Duration_Min,
|
| 28 |
+
Feasible, Reason, Loading_Time
|
| 29 |
+
"""
|
| 30 |
+
bike_dur = phub.matrices['bike']['dur']
|
| 31 |
+
demand = phub.demand
|
| 32 |
+
|
| 33 |
+
rows = []
|
| 34 |
+
|
| 35 |
+
for ii, h in enumerate(hubs):
|
| 36 |
+
# Nodes with positive demand
|
| 37 |
+
hub_nodes = allocations[ii]
|
| 38 |
+
unserved = [n for n in hub_nodes if demand[n] > 0]
|
| 39 |
+
|
| 40 |
+
while unserved:
|
| 41 |
+
# Pick starting node by shortest simple cycle hub->n->hub
|
| 42 |
+
start_costs = []
|
| 43 |
+
start_nodes = []
|
| 44 |
+
|
| 45 |
+
for n in unserved:
|
| 46 |
+
t_out = dur_safe(bike_dur, h, n)
|
| 47 |
+
t_back = dur_safe(bike_dur, n, h)
|
| 48 |
+
cyc = t_out + t_back
|
| 49 |
+
if np.isfinite(cyc):
|
| 50 |
+
start_costs.append(cyc)
|
| 51 |
+
start_nodes.append(n)
|
| 52 |
+
|
| 53 |
+
if not start_costs:
|
| 54 |
+
# Mark all as unreachable
|
| 55 |
+
for n in unserved:
|
| 56 |
+
rows.append({
|
| 57 |
+
'Hub': h,
|
| 58 |
+
'Route': route_to_str([h, n]),
|
| 59 |
+
'Breakdown_Dur': "[NA(h->n), NA(n->h)]",
|
| 60 |
+
'Total_Duration_Min': np.nan,
|
| 61 |
+
'Feasible': False,
|
| 62 |
+
'Reason': "No available bike link(s) (NaN)",
|
| 63 |
+
'Loading_Time': LoadingTime
|
| 64 |
+
})
|
| 65 |
+
unserved = []
|
| 66 |
+
break
|
| 67 |
+
|
| 68 |
+
idx_min = np.argmin(start_costs)
|
| 69 |
+
n1 = start_nodes[idx_min]
|
| 70 |
+
|
| 71 |
+
nodes = [n1]
|
| 72 |
+
total, legs = route_total_and_breakdown_with_loading(bike_dur, h, nodes, LoadingTime)
|
| 73 |
+
|
| 74 |
+
# Try adding one more node if allowed
|
| 75 |
+
if len(nodes) + 1 < MaxRouteNodes:
|
| 76 |
+
best_extra_total = np.inf
|
| 77 |
+
best_extra_legs = []
|
| 78 |
+
best_n2 = None
|
| 79 |
+
|
| 80 |
+
for n2 in unserved:
|
| 81 |
+
if n2 == n1:
|
| 82 |
+
continue
|
| 83 |
+
ntot, nlegs = route_total_and_breakdown_with_loading(bike_dur, h, [n1, n2], LoadingTime)
|
| 84 |
+
if np.isfinite(ntot) and ntot < best_extra_total:
|
| 85 |
+
best_extra_total = ntot
|
| 86 |
+
best_extra_legs = nlegs
|
| 87 |
+
best_n2 = n2
|
| 88 |
+
|
| 89 |
+
if best_n2 is not None and best_extra_total <= MaxTotalMinutes + 1e-9:
|
| 90 |
+
nodes = [n1, best_n2]
|
| 91 |
+
total = best_extra_total
|
| 92 |
+
legs = best_extra_legs
|
| 93 |
+
|
| 94 |
+
feasible = np.isfinite(total) and (total <= MaxTotalMinutes + 1e-9)
|
| 95 |
+
breakdown_str = breakdown_to_str(legs)
|
| 96 |
+
reason = ""
|
| 97 |
+
if not feasible:
|
| 98 |
+
if np.isfinite(total):
|
| 99 |
+
reason = "Exceeded cap"
|
| 100 |
+
else:
|
| 101 |
+
reason = "No available bike link(s) (NaN)"
|
| 102 |
+
|
| 103 |
+
rows.append({
|
| 104 |
+
'Hub': h,
|
| 105 |
+
'Route': route_to_str([h] + nodes),
|
| 106 |
+
'Breakdown_Dur': breakdown_str,
|
| 107 |
+
'Total_Duration_Min': conditional_nan(total),
|
| 108 |
+
'Feasible': feasible,
|
| 109 |
+
'Reason': reason,
|
| 110 |
+
'Loading_Time': LoadingTime
|
| 111 |
+
})
|
| 112 |
+
|
| 113 |
+
# Remove served nodes
|
| 114 |
+
for n in nodes:
|
| 115 |
+
if n in unserved:
|
| 116 |
+
unserved.remove(n)
|
| 117 |
+
|
| 118 |
+
T = pd.DataFrame(rows)
|
| 119 |
+
|
| 120 |
+
if SaveCsvPath:
|
| 121 |
+
T.to_csv(SaveCsvPath, index=False)
|
| 122 |
+
|
| 123 |
+
return T
|
| 124 |
+
|
| 125 |
+
def dur_safe(dur_mat, i, j):
|
| 126 |
+
"""Safe duration lookup, returns inf for NaN"""
|
| 127 |
+
v = dur_mat[i, j]
|
| 128 |
+
if np.isnan(v) or np.isinf(v):
|
| 129 |
+
return np.inf
|
| 130 |
+
return float(v)
|
| 131 |
+
|
| 132 |
+
def route_total_and_breakdown_with_loading(dur_mat, hub, nodes, loading_time):
|
| 133 |
+
"""
|
| 134 |
+
Calculate total time and breakdown for a route with loading times.
|
| 135 |
+
|
| 136 |
+
Returns:
|
| 137 |
+
total: total time in minutes
|
| 138 |
+
legs: list of leg times including loading times
|
| 139 |
+
"""
|
| 140 |
+
if not nodes:
|
| 141 |
+
return 0.0, []
|
| 142 |
+
|
| 143 |
+
legs = []
|
| 144 |
+
total = 0.0
|
| 145 |
+
|
| 146 |
+
# Hub -> first
|
| 147 |
+
t = dur_safe(dur_mat, hub, nodes[0])
|
| 148 |
+
if not np.isfinite(t):
|
| 149 |
+
return np.inf, []
|
| 150 |
+
legs.append(t)
|
| 151 |
+
total += t
|
| 152 |
+
|
| 153 |
+
# Loading at first stop
|
| 154 |
+
legs.append(loading_time)
|
| 155 |
+
total += loading_time
|
| 156 |
+
|
| 157 |
+
# Internal legs with loading on arrival
|
| 158 |
+
for k in range(len(nodes) - 1):
|
| 159 |
+
t_ab = dur_safe(dur_mat, nodes[k], nodes[k + 1])
|
| 160 |
+
if not np.isfinite(t_ab):
|
| 161 |
+
return np.inf, []
|
| 162 |
+
legs.append(t_ab)
|
| 163 |
+
total += t_ab
|
| 164 |
+
legs.append(loading_time)
|
| 165 |
+
total += loading_time
|
| 166 |
+
|
| 167 |
+
# Last -> hub, no loading after return
|
| 168 |
+
t_back = dur_safe(dur_mat, nodes[-1], hub)
|
| 169 |
+
if not np.isfinite(t_back):
|
| 170 |
+
return np.inf, []
|
| 171 |
+
legs.append(t_back)
|
| 172 |
+
total += t_back
|
| 173 |
+
|
| 174 |
+
return total, legs
|
| 175 |
+
|
| 176 |
+
def route_to_str(v):
|
| 177 |
+
"""Convert route to string representation"""
|
| 178 |
+
return "[" + ", ".join(str(x) for x in v) + "]"
|
| 179 |
+
|
| 180 |
+
def breakdown_to_str(v):
|
| 181 |
+
"""Convert breakdown to string representation"""
|
| 182 |
+
if not v:
|
| 183 |
+
return "[]"
|
| 184 |
+
return "[" + ", ".join(f"{x:.3f}" for x in v) + "]"
|
| 185 |
+
|
| 186 |
+
def conditional_nan(v):
|
| 187 |
+
"""Return rounded value if finite, else NaN"""
|
| 188 |
+
if np.isfinite(v):
|
| 189 |
+
return round(v, 3)
|
| 190 |
+
return np.nan
|
phub_allocation.py
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
P-Hub Allocation class for hub selection and node assignment
|
| 3 |
+
Complete implementation converted from MATLAB
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pandas as pd
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from typing import Dict, List, Tuple, Optional
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class ModeInfo:
|
| 13 |
+
"""Transport mode information"""
|
| 14 |
+
id: str
|
| 15 |
+
distance_cost: float
|
| 16 |
+
duration_cost: float
|
| 17 |
+
fixed_cost: float
|
| 18 |
+
max_dist: float
|
| 19 |
+
speed: float
|
| 20 |
+
max_duration: float
|
| 21 |
+
|
| 22 |
+
class PHubAllocation:
|
| 23 |
+
"""
|
| 24 |
+
P-Hub Allocation solver for multi-modal transportation networks.
|
| 25 |
+
|
| 26 |
+
Solves hub location and customer assignment problems where:
|
| 27 |
+
- Source -> Hub uses {VAN, DRONE}
|
| 28 |
+
- Hub -> Node uses {BIKE}
|
| 29 |
+
- Hub -> Node must be <= 25 minutes (configurable)
|
| 30 |
+
|
| 31 |
+
Matrices + coordinates + demand auto-loaded from Excel.
|
| 32 |
+
NaN entries are treated as "unavailable".
|
| 33 |
+
|
| 34 |
+
Attributes:
|
| 35 |
+
excelPath (str): Path to input Excel file
|
| 36 |
+
nNodes (int): Number of nodes in the network
|
| 37 |
+
nodes (np.ndarray): Array of node indices
|
| 38 |
+
source (int): Source node index (0-based)
|
| 39 |
+
pHubs (int): Number of hubs to allocate
|
| 40 |
+
autoExpandHubs (bool): Auto-expand hubs if infeasible
|
| 41 |
+
matrices (dict): Distance and duration matrices for each mode
|
| 42 |
+
coordinates (np.ndarray): Node coordinates (n x 2)
|
| 43 |
+
demand (np.ndarray): Demand at each node (n x 1)
|
| 44 |
+
hnMaxMinutes (float): Maximum Hub->Node time in minutes
|
| 45 |
+
modes (dict): Mode parameters (van, bike, drone)
|
| 46 |
+
nodeOrder (np.ndarray): Order of node labels
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
def __init__(self, excel_path: str, p_hubs: int = 4, random_seed: Optional[int] = None,
|
| 50 |
+
auto_expand_hubs: bool = True, n_nodes: Optional[int] = None,
|
| 51 |
+
sheet_map: Optional[Dict] = None):
|
| 52 |
+
"""
|
| 53 |
+
Initialize P-Hub Allocation solver.
|
| 54 |
+
|
| 55 |
+
Args:
|
| 56 |
+
excel_path: Path to Excel file with matrices
|
| 57 |
+
p_hubs: Number of hubs to allocate (default 4)
|
| 58 |
+
random_seed: Random seed for reproducibility (optional)
|
| 59 |
+
auto_expand_hubs: Auto-increase hubs if infeasible (default True)
|
| 60 |
+
n_nodes: Number of nodes (inferred from matrices if None)
|
| 61 |
+
sheet_map: Custom sheet name mapping (optional)
|
| 62 |
+
"""
|
| 63 |
+
self.excelPath = excel_path
|
| 64 |
+
self.pHubs = p_hubs
|
| 65 |
+
self.autoExpandHubs = auto_expand_hubs
|
| 66 |
+
|
| 67 |
+
if random_seed is not None:
|
| 68 |
+
np.random.seed(random_seed)
|
| 69 |
+
|
| 70 |
+
# Default sheet mapping
|
| 71 |
+
if sheet_map is None:
|
| 72 |
+
self.sheetMap = {
|
| 73 |
+
'van_dist': 'Van_dist',
|
| 74 |
+
'van_dur': 'Van_dur',
|
| 75 |
+
'bike_dist': 'Bike_dist',
|
| 76 |
+
'bike_dur': 'Bike_dur',
|
| 77 |
+
'drone_dist': 'Drone_dist',
|
| 78 |
+
'drone_dur': 'Drone_dur',
|
| 79 |
+
'coord': 'Coordinate',
|
| 80 |
+
'demand': 'Demand'
|
| 81 |
+
}
|
| 82 |
+
else:
|
| 83 |
+
self.sheetMap = sheet_map
|
| 84 |
+
|
| 85 |
+
# Load matrices
|
| 86 |
+
self.matrices = self.load_all_matrices_from_excel()
|
| 87 |
+
|
| 88 |
+
# Determine number of nodes
|
| 89 |
+
inferred_n = self.matrices['bike']['dist'].shape[0]
|
| 90 |
+
self.nNodes = n_nodes if n_nodes is not None else inferred_n
|
| 91 |
+
|
| 92 |
+
if n_nodes is not None and self.nNodes != inferred_n:
|
| 93 |
+
raise ValueError(f"nNodes={n_nodes} does not match Excel matrices ({inferred_n}).")
|
| 94 |
+
|
| 95 |
+
self.nodes = np.arange(self.nNodes)
|
| 96 |
+
self.source = 0 # 0-based indexing in Python (MATLAB uses 1)
|
| 97 |
+
|
| 98 |
+
# Define transport modes with costs
|
| 99 |
+
self.modes = {
|
| 100 |
+
'van': ModeInfo(
|
| 101 |
+
id="M1",
|
| 102 |
+
distance_cost=0.15, # £/km
|
| 103 |
+
duration_cost=25.0, # £/hour
|
| 104 |
+
fixed_cost=50.0, # £
|
| 105 |
+
max_dist=200.0, # km
|
| 106 |
+
speed=60.0, # km/h
|
| 107 |
+
max_duration=8.0 # hours
|
| 108 |
+
),
|
| 109 |
+
'bike': ModeInfo(
|
| 110 |
+
id="M2",
|
| 111 |
+
distance_cost=0.05, # £/km
|
| 112 |
+
duration_cost=15.0, # £/hour
|
| 113 |
+
fixed_cost=5.0, # £
|
| 114 |
+
max_dist=15.0, # km
|
| 115 |
+
speed=18.0, # km/h
|
| 116 |
+
max_duration=1.0 # hours
|
| 117 |
+
),
|
| 118 |
+
'drone': ModeInfo(
|
| 119 |
+
id="M3",
|
| 120 |
+
distance_cost=0.10, # £/km
|
| 121 |
+
duration_cost=2.0, # £/hour
|
| 122 |
+
fixed_cost=15.0, # £
|
| 123 |
+
max_dist=80.0, # km
|
| 124 |
+
speed=70.0, # km/h
|
| 125 |
+
max_duration=1.5 # hours
|
| 126 |
+
)
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
# Load coordinates and demand
|
| 130 |
+
self.coordinates = self.read_coordinates_sheet(self.sheetMap['coord'])
|
| 131 |
+
self.demand = self.read_demand_sheet(self.sheetMap['demand'])
|
| 132 |
+
self.demand[0] = 0 # Source has zero demand
|
| 133 |
+
|
| 134 |
+
# Hub->Node maximum time constraint
|
| 135 |
+
self.hnMaxMinutes = 12.5
|
| 136 |
+
|
| 137 |
+
def load_all_matrices_from_excel(self) -> Dict:
|
| 138 |
+
"""
|
| 139 |
+
Load all distance and duration matrices from Excel.
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
Dict with structure: {'van': {'dist': arr, 'dur': arr}, ...}
|
| 143 |
+
"""
|
| 144 |
+
# Load bike matrices first to determine node order
|
| 145 |
+
bike_dist, bike_nodes = self.read_matrix_sheet(self.sheetMap['bike_dist'])
|
| 146 |
+
bike_dur, bike_nodes2 = self.read_matrix_sheet(self.sheetMap['bike_dur'])
|
| 147 |
+
|
| 148 |
+
# Determine node label order
|
| 149 |
+
if len(bike_nodes) > 0:
|
| 150 |
+
label_order = bike_nodes
|
| 151 |
+
elif len(bike_nodes2) > 0:
|
| 152 |
+
label_order = bike_nodes2
|
| 153 |
+
else:
|
| 154 |
+
label_order = np.arange(bike_dist.shape[0])
|
| 155 |
+
|
| 156 |
+
self.nodeOrder = label_order
|
| 157 |
+
|
| 158 |
+
# Load other matrices
|
| 159 |
+
van_dist, van_nodes = self.read_matrix_sheet(self.sheetMap['van_dist'])
|
| 160 |
+
van_dur, van_nodes2 = self.read_matrix_sheet(self.sheetMap['van_dur'])
|
| 161 |
+
drone_dist, drone_nodes = self.read_matrix_sheet(self.sheetMap['drone_dist'])
|
| 162 |
+
drone_dur, drone_nodes2 = self.read_matrix_sheet(self.sheetMap['drone_dur'])
|
| 163 |
+
|
| 164 |
+
# Reindex if needed (simplified - assumes matching order)
|
| 165 |
+
# In production, you'd use reindexToOrder for each matrix
|
| 166 |
+
|
| 167 |
+
# Zero diagonal NaN values
|
| 168 |
+
bike_dist = self.zero_diag_nans(bike_dist)
|
| 169 |
+
bike_dur = self.zero_diag_nans(bike_dur)
|
| 170 |
+
van_dist = self.zero_diag_nans(van_dist)
|
| 171 |
+
van_dur = self.zero_diag_nans(van_dur)
|
| 172 |
+
drone_dist = self.zero_diag_nans(drone_dist)
|
| 173 |
+
drone_dur = self.zero_diag_nans(drone_dur)
|
| 174 |
+
|
| 175 |
+
return {
|
| 176 |
+
'van': {'dist': van_dist, 'dur': van_dur},
|
| 177 |
+
'bike': {'dist': bike_dist, 'dur': bike_dur},
|
| 178 |
+
'drone': {'dist': drone_dist, 'dur': drone_dur}
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
def read_matrix_sheet(self, sheet_name: str) -> Tuple[np.ndarray, np.ndarray]:
|
| 182 |
+
"""
|
| 183 |
+
Read matrix from Excel sheet.
|
| 184 |
+
|
| 185 |
+
Args:
|
| 186 |
+
sheet_name: Name of Excel sheet
|
| 187 |
+
|
| 188 |
+
Returns:
|
| 189 |
+
Tuple of (matrix, row_labels)
|
| 190 |
+
"""
|
| 191 |
+
try:
|
| 192 |
+
# Try reading with labels in first row/column
|
| 193 |
+
df = pd.read_excel(self.excelPath, sheet_name=sheet_name, header=0, index_col=0)
|
| 194 |
+
core = df.values.astype(float)
|
| 195 |
+
labels = df.index.values.astype(float)
|
| 196 |
+
return core, labels
|
| 197 |
+
except Exception as e:
|
| 198 |
+
try:
|
| 199 |
+
# Fallback: read as raw cell array
|
| 200 |
+
C = pd.read_excel(self.excelPath, sheet_name=sheet_name, header=None)
|
| 201 |
+
|
| 202 |
+
# Check for O-D marker
|
| 203 |
+
first_cell = str(C.iloc[0, 0]).lower() if not pd.isna(C.iloc[0, 0]) else ""
|
| 204 |
+
|
| 205 |
+
if first_cell in ['o-d', 'od', 'o_d']:
|
| 206 |
+
# Skip first row/column
|
| 207 |
+
arr = C.iloc[1:, 1:].values
|
| 208 |
+
return self.to_numeric_matrix(arr), np.array([])
|
| 209 |
+
|
| 210 |
+
# Try extracting labels from first row/column
|
| 211 |
+
try:
|
| 212 |
+
row_labels = self.to_numeric_vector(C.iloc[1:, 0].values)
|
| 213 |
+
col_labels = self.to_numeric_vector(C.iloc[0, 1:].values)
|
| 214 |
+
|
| 215 |
+
if (not np.any(np.isnan(row_labels)) and not np.any(np.isnan(col_labels))):
|
| 216 |
+
# Filter to common labels
|
| 217 |
+
common_mask = np.isin(row_labels, col_labels)
|
| 218 |
+
common_rows = row_labels[common_mask]
|
| 219 |
+
|
| 220 |
+
# Build column index map
|
| 221 |
+
col_map = {col_labels[j]: j for j in range(len(col_labels))}
|
| 222 |
+
col_idx = [col_map[r] for r in common_rows]
|
| 223 |
+
|
| 224 |
+
# Extract matrix
|
| 225 |
+
num_core = np.full((len(common_rows), len(common_rows)), np.nan)
|
| 226 |
+
for i in range(len(common_rows)):
|
| 227 |
+
for j in range(len(common_rows)):
|
| 228 |
+
val = C.iloc[i + 1, col_idx[j] + 1]
|
| 229 |
+
num_core[i, j] = float(val) if pd.notna(val) else np.nan
|
| 230 |
+
|
| 231 |
+
return self.zero_diag_nans(num_core), common_rows
|
| 232 |
+
except:
|
| 233 |
+
pass
|
| 234 |
+
|
| 235 |
+
# Final fallback: numeric read
|
| 236 |
+
arr = pd.read_excel(self.excelPath, sheet_name=sheet_name, header=None).values
|
| 237 |
+
return self.to_numeric_matrix(arr), np.array([])
|
| 238 |
+
|
| 239 |
+
except Exception as e2:
|
| 240 |
+
raise ValueError(f"Could not read sheet '{sheet_name}': {e2}")
|
| 241 |
+
|
| 242 |
+
def read_coordinates_sheet(self, sheet_name: str) -> np.ndarray:
|
| 243 |
+
"""
|
| 244 |
+
Read coordinates from Excel sheet.
|
| 245 |
+
|
| 246 |
+
Args:
|
| 247 |
+
sheet_name: Name of Excel sheet
|
| 248 |
+
|
| 249 |
+
Returns:
|
| 250 |
+
Array of shape (n, 2) with x, y coordinates
|
| 251 |
+
"""
|
| 252 |
+
T = pd.read_excel(self.excelPath, sheet_name=sheet_name)
|
| 253 |
+
T.columns = [c.lower().strip() for c in T.columns]
|
| 254 |
+
|
| 255 |
+
# Find x, y columns
|
| 256 |
+
if 'x' in T.columns and 'y' in T.columns:
|
| 257 |
+
xy = T[['x', 'y']].values
|
| 258 |
+
elif 'lon' in T.columns and 'lat' in T.columns:
|
| 259 |
+
xy = T[['lon', 'lat']].values
|
| 260 |
+
elif 'longitude' in T.columns and 'latitude' in T.columns:
|
| 261 |
+
xy = T[['longitude', 'latitude']].values
|
| 262 |
+
else:
|
| 263 |
+
# Use first two numeric columns
|
| 264 |
+
num_cols = [c for c in T.columns if pd.api.types.is_numeric_dtype(T[c])]
|
| 265 |
+
if len(num_cols) < 2:
|
| 266 |
+
raise ValueError(f"Coordinates sheet '{sheet_name}' needs at least two numeric columns.")
|
| 267 |
+
xy = T[num_cols[:2]].values
|
| 268 |
+
|
| 269 |
+
# Ensure we have enough rows
|
| 270 |
+
if xy.shape[0] < self.nNodes:
|
| 271 |
+
raise ValueError(f"Coordinates sheet has {xy.shape[0]} rows, need {self.nNodes}.")
|
| 272 |
+
|
| 273 |
+
return xy[:self.nNodes, :]
|
| 274 |
+
|
| 275 |
+
def read_demand_sheet(self, sheet_name: str) -> np.ndarray:
|
| 276 |
+
"""
|
| 277 |
+
Read demand from Excel sheet.
|
| 278 |
+
|
| 279 |
+
Args:
|
| 280 |
+
sheet_name: Name of Excel sheet
|
| 281 |
+
|
| 282 |
+
Returns:
|
| 283 |
+
Array of shape (n,) with demand values
|
| 284 |
+
"""
|
| 285 |
+
T = pd.read_excel(self.excelPath, sheet_name=sheet_name)
|
| 286 |
+
T.columns = [c.lower().strip() for c in T.columns]
|
| 287 |
+
|
| 288 |
+
# Find demand column
|
| 289 |
+
if 'demand' in T.columns:
|
| 290 |
+
d = T['demand'].values
|
| 291 |
+
else:
|
| 292 |
+
# Use first numeric column
|
| 293 |
+
num_cols = [c for c in T.columns if pd.api.types.is_numeric_dtype(T[c])]
|
| 294 |
+
if not num_cols:
|
| 295 |
+
raise ValueError(f"Demand sheet '{sheet_name}' needs a numeric column.")
|
| 296 |
+
d = T[num_cols[0]].values
|
| 297 |
+
|
| 298 |
+
# Replace NaN with 0
|
| 299 |
+
d = np.nan_to_num(d, nan=0.0)
|
| 300 |
+
|
| 301 |
+
# Ensure we have enough values
|
| 302 |
+
if len(d) < self.nNodes:
|
| 303 |
+
raise ValueError(f"Demand sheet has {len(d)} values, need {self.nNodes}.")
|
| 304 |
+
|
| 305 |
+
return d[:self.nNodes]
|
| 306 |
+
|
| 307 |
+
def zero_diag_nans(self, A: np.ndarray) -> np.ndarray:
|
| 308 |
+
"""Set diagonal NaN values to zero."""
|
| 309 |
+
n = A.shape[0]
|
| 310 |
+
for i in range(n):
|
| 311 |
+
if np.isnan(A[i, i]):
|
| 312 |
+
A[i, i] = 0.0
|
| 313 |
+
return A
|
| 314 |
+
|
| 315 |
+
def to_numeric_matrix(self, arr) -> np.ndarray:
|
| 316 |
+
"""Convert array to numeric matrix, handling NaN."""
|
| 317 |
+
if isinstance(arr, np.ndarray) and np.issubdtype(arr.dtype, np.number):
|
| 318 |
+
return arr.astype(float)
|
| 319 |
+
|
| 320 |
+
mat = np.zeros(arr.shape)
|
| 321 |
+
for i in range(arr.shape[0]):
|
| 322 |
+
for j in range(arr.shape[1]):
|
| 323 |
+
val = arr[i, j] if hasattr(arr, '__getitem__') else arr
|
| 324 |
+
if pd.notna(val):
|
| 325 |
+
try:
|
| 326 |
+
mat[i, j] = float(val)
|
| 327 |
+
except (ValueError, TypeError):
|
| 328 |
+
mat[i, j] = np.nan
|
| 329 |
+
else:
|
| 330 |
+
mat[i, j] = np.nan
|
| 331 |
+
return mat
|
| 332 |
+
|
| 333 |
+
def to_numeric_vector(self, vec) -> np.ndarray:
|
| 334 |
+
"""Convert vector to numeric array."""
|
| 335 |
+
result = np.zeros(len(vec))
|
| 336 |
+
for i, val in enumerate(vec):
|
| 337 |
+
if pd.notna(val):
|
| 338 |
+
try:
|
| 339 |
+
result[i] = float(val)
|
| 340 |
+
except (ValueError, TypeError):
|
| 341 |
+
result[i] = np.nan
|
| 342 |
+
else:
|
| 343 |
+
result[i] = np.nan
|
| 344 |
+
return result
|
| 345 |
+
|
| 346 |
+
def is_HN_feasible(self, dist_hn: float, dur_hn: float) -> bool:
|
| 347 |
+
"""
|
| 348 |
+
Check if Hub->Node route is feasible.
|
| 349 |
+
|
| 350 |
+
Args:
|
| 351 |
+
dist_hn: Distance in km
|
| 352 |
+
dur_hn: Duration in minutes
|
| 353 |
+
|
| 354 |
+
Returns:
|
| 355 |
+
True if feasible, False otherwise
|
| 356 |
+
"""
|
| 357 |
+
if np.isnan(dist_hn) or np.isnan(dur_hn):
|
| 358 |
+
return False
|
| 359 |
+
|
| 360 |
+
b = self.modes['bike']
|
| 361 |
+
if dist_hn > b.max_dist + 1e-9:
|
| 362 |
+
return False
|
| 363 |
+
if dur_hn > min(b.max_duration * 60, self.hnMaxMinutes) + 1e-9:
|
| 364 |
+
return False
|
| 365 |
+
|
| 366 |
+
return True
|
| 367 |
+
|
| 368 |
+
def is_mode_feasible(self, distance: float, duration: float, info: ModeInfo) -> bool:
|
| 369 |
+
"""
|
| 370 |
+
Check if mode is feasible for given distance/duration.
|
| 371 |
+
|
| 372 |
+
Args:
|
| 373 |
+
distance: Distance in km
|
| 374 |
+
duration: Duration in minutes
|
| 375 |
+
info: Mode information
|
| 376 |
+
|
| 377 |
+
Returns:
|
| 378 |
+
True if feasible, False otherwise
|
| 379 |
+
"""
|
| 380 |
+
if np.isnan(distance) or np.isnan(duration):
|
| 381 |
+
return False
|
| 382 |
+
if distance > info.max_dist + 1e-9:
|
| 383 |
+
return False
|
| 384 |
+
if duration > info.max_duration * 60 + 1e-9:
|
| 385 |
+
return False
|
| 386 |
+
return True
|
| 387 |
+
|
| 388 |
+
def calc_cost(self, distance: float, duration: float, info: ModeInfo, demand: float) -> float:
|
| 389 |
+
"""
|
| 390 |
+
Calculate cost for given route.
|
| 391 |
+
|
| 392 |
+
Args:
|
| 393 |
+
distance: Distance in km
|
| 394 |
+
duration: Duration in minutes
|
| 395 |
+
info: Mode information
|
| 396 |
+
demand: Demand to transport
|
| 397 |
+
|
| 398 |
+
Returns:
|
| 399 |
+
Total cost in £
|
| 400 |
+
"""
|
| 401 |
+
if np.isnan(distance) or np.isnan(duration):
|
| 402 |
+
raise ValueError('Tried to compute cost with NaN distance/duration — route unavailable.')
|
| 403 |
+
|
| 404 |
+
distance_cost = info.distance_cost * distance
|
| 405 |
+
duration_cost = info.duration_cost * (duration / 60.0)
|
| 406 |
+
fixed_cost = info.fixed_cost
|
| 407 |
+
|
| 408 |
+
return (distance_cost + duration_cost + fixed_cost) * demand
|
| 409 |
+
|
| 410 |
+
def best_mode_SH(self, hub: int, hub_demand: float) -> Tuple[str, ModeInfo]:
|
| 411 |
+
"""
|
| 412 |
+
Find best mode for Source->Hub.
|
| 413 |
+
|
| 414 |
+
Args:
|
| 415 |
+
hub: Hub node index
|
| 416 |
+
hub_demand: Total demand at hub
|
| 417 |
+
|
| 418 |
+
Returns:
|
| 419 |
+
Tuple of (mode_name, mode_info)
|
| 420 |
+
"""
|
| 421 |
+
best_cost = np.inf
|
| 422 |
+
best_mode = ""
|
| 423 |
+
best_info = None
|
| 424 |
+
|
| 425 |
+
for k in ['van', 'drone']:
|
| 426 |
+
info = self.modes[k]
|
| 427 |
+
dist = self.matrices[k]['dist'][self.source, hub]
|
| 428 |
+
dur = self.matrices[k]['dur'][self.source, hub]
|
| 429 |
+
|
| 430 |
+
if not self.is_mode_feasible(dist, dur, info):
|
| 431 |
+
continue
|
| 432 |
+
|
| 433 |
+
cost = self.calc_cost(dist, dur, info, hub_demand)
|
| 434 |
+
if cost < best_cost:
|
| 435 |
+
best_cost = cost
|
| 436 |
+
best_mode = k
|
| 437 |
+
best_info = info
|
| 438 |
+
|
| 439 |
+
if best_cost == np.inf:
|
| 440 |
+
raise ValueError(f'No feasible S->H mode for hub {hub}')
|
| 441 |
+
|
| 442 |
+
return best_mode, best_info
|
| 443 |
+
|
| 444 |
+
def select_hubs_k_center(self, p_target: int) -> np.ndarray:
|
| 445 |
+
"""
|
| 446 |
+
Select hubs using k-center algorithm.
|
| 447 |
+
|
| 448 |
+
Iteratively selects hubs to maximize minimum distance between
|
| 449 |
+
customers and their nearest hub.
|
| 450 |
+
|
| 451 |
+
Args:
|
| 452 |
+
p_target: Number of hubs to select
|
| 453 |
+
|
| 454 |
+
Returns:
|
| 455 |
+
Array of hub indices
|
| 456 |
+
"""
|
| 457 |
+
hubs = [self.source] # Start with source node
|
| 458 |
+
bike_dist = self.matrices['bike']['dist']
|
| 459 |
+
|
| 460 |
+
while len(hubs) < p_target:
|
| 461 |
+
dmin = np.zeros(self.nNodes)
|
| 462 |
+
|
| 463 |
+
for i in range(self.nNodes):
|
| 464 |
+
if i in hubs:
|
| 465 |
+
dmin[i] = 0.0
|
| 466 |
+
else:
|
| 467 |
+
# Distance to nearest hub
|
| 468 |
+
vals = [bike_dist[h, i] if not np.isnan(bike_dist[h, i]) else np.inf
|
| 469 |
+
for h in hubs]
|
| 470 |
+
dmin[i] = min(vals)
|
| 471 |
+
|
| 472 |
+
# Select node farthest from all hubs
|
| 473 |
+
next_hub = np.argmax(dmin)
|
| 474 |
+
|
| 475 |
+
# If already selected, find alternative
|
| 476 |
+
if next_hub in hubs:
|
| 477 |
+
candidates = [i for i in range(self.nNodes) if i not in hubs]
|
| 478 |
+
if not candidates:
|
| 479 |
+
break
|
| 480 |
+
|
| 481 |
+
d_to_h = []
|
| 482 |
+
for i in candidates:
|
| 483 |
+
vals = [bike_dist[h, i] if not np.isnan(bike_dist[h, i]) else np.inf
|
| 484 |
+
for h in hubs]
|
| 485 |
+
d_to_h.append((i, min(vals)))
|
| 486 |
+
|
| 487 |
+
next_hub = max(d_to_h, key=lambda x: x[1])[0]
|
| 488 |
+
|
| 489 |
+
hubs.append(next_hub)
|
| 490 |
+
|
| 491 |
+
return np.array(hubs)
|
| 492 |
+
|
| 493 |
+
def assign_nodes_bike_feasible(self, hubs: np.ndarray) -> Tuple[List, List]:
|
| 494 |
+
"""
|
| 495 |
+
Assign nodes to hubs based on bike feasibility.
|
| 496 |
+
|
| 497 |
+
Each customer is assigned to the nearest hub where bike
|
| 498 |
+
delivery is feasible (satisfies distance and time constraints).
|
| 499 |
+
|
| 500 |
+
Args:
|
| 501 |
+
hubs: Array of hub indices
|
| 502 |
+
|
| 503 |
+
Returns:
|
| 504 |
+
Tuple of (allocations, infeasible) where:
|
| 505 |
+
- allocations: List of lists, allocations[i] = customers for hub i
|
| 506 |
+
- infeasible: List of customers that cannot be assigned
|
| 507 |
+
"""
|
| 508 |
+
allocations = [[] for _ in range(len(hubs))]
|
| 509 |
+
infeasible = []
|
| 510 |
+
|
| 511 |
+
bike_dist = self.matrices['bike']['dist']
|
| 512 |
+
bike_dur = self.matrices['bike']['dur']
|
| 513 |
+
|
| 514 |
+
for node in range(self.nNodes):
|
| 515 |
+
if node in hubs:
|
| 516 |
+
continue
|
| 517 |
+
|
| 518 |
+
# Sort hubs by distance to this node
|
| 519 |
+
distances = [(h, bike_dist[h, node] if not np.isnan(bike_dist[h, node]) else np.inf)
|
| 520 |
+
for h in hubs]
|
| 521 |
+
distances.sort(key=lambda x: x[1])
|
| 522 |
+
|
| 523 |
+
# Try hubs in order of distance
|
| 524 |
+
chosen = None
|
| 525 |
+
for h, _ in distances:
|
| 526 |
+
d_hn = bike_dist[h, node]
|
| 527 |
+
t_hn = bike_dur[h, node]
|
| 528 |
+
|
| 529 |
+
if self.is_HN_feasible(d_hn, t_hn):
|
| 530 |
+
chosen = h
|
| 531 |
+
break
|
| 532 |
+
|
| 533 |
+
if chosen is None:
|
| 534 |
+
infeasible.append(node)
|
| 535 |
+
else:
|
| 536 |
+
k = list(hubs).index(chosen)
|
| 537 |
+
allocations[k].append(node)
|
| 538 |
+
|
| 539 |
+
return allocations, infeasible
|
| 540 |
+
|
| 541 |
+
def solve(self) -> Tuple[np.ndarray, List, float, Dict, int, Dict]:
|
| 542 |
+
"""
|
| 543 |
+
Solve p-hub allocation problem.
|
| 544 |
+
|
| 545 |
+
Returns:
|
| 546 |
+
Tuple of:
|
| 547 |
+
- hubs: Array of hub indices
|
| 548 |
+
- allocations: List of customer assignments per hub
|
| 549 |
+
- total_cost: Total cost in £
|
| 550 |
+
- sh_modes: Dict with Source->Hub mode info
|
| 551 |
+
- p_eff: Effective number of hubs used
|
| 552 |
+
- cost_breakdown: Dict with cost components
|
| 553 |
+
"""
|
| 554 |
+
p_eff = self.pHubs
|
| 555 |
+
|
| 556 |
+
while True:
|
| 557 |
+
# Select hubs
|
| 558 |
+
hubs = self.select_hubs_k_center(p_eff)
|
| 559 |
+
|
| 560 |
+
# Assign customers to hubs
|
| 561 |
+
allocations, infeasible = self.assign_nodes_bike_feasible(hubs)
|
| 562 |
+
|
| 563 |
+
if not infeasible:
|
| 564 |
+
break
|
| 565 |
+
|
| 566 |
+
if self.autoExpandHubs and p_eff < self.nNodes:
|
| 567 |
+
p_eff += 1
|
| 568 |
+
else:
|
| 569 |
+
raise ValueError(f'Infeasible nodes with p={p_eff}. Increase p_hubs or adjust constraints.')
|
| 570 |
+
|
| 571 |
+
# Prepare Source->Hub modes
|
| 572 |
+
sh_modes = {'modeName': [], 'info': []}
|
| 573 |
+
for i, h in enumerate(hubs):
|
| 574 |
+
hub_nodes = allocations[i]
|
| 575 |
+
hub_demand = sum(self.demand[n] for n in hub_nodes)
|
| 576 |
+
mname, mi = self.best_mode_SH(h, hub_demand)
|
| 577 |
+
sh_modes['modeName'].append(mname)
|
| 578 |
+
sh_modes['info'].append(mi)
|
| 579 |
+
|
| 580 |
+
# Calculate costs
|
| 581 |
+
total_cost = 0.0
|
| 582 |
+
cost_breakdown = {'S_to_H': 0.0, 'H_to_N': 0.0, 'fixed': 0.0}
|
| 583 |
+
|
| 584 |
+
for i, h in enumerate(hubs):
|
| 585 |
+
hub_nodes = allocations[i]
|
| 586 |
+
hub_demand = sum(self.demand[n] for n in hub_nodes)
|
| 587 |
+
|
| 588 |
+
# Source->Hub cost
|
| 589 |
+
mname = sh_modes['modeName'][i]
|
| 590 |
+
mi = sh_modes['info'][i]
|
| 591 |
+
distSH = self.matrices[mname]['dist'][self.source, h]
|
| 592 |
+
durSH = self.matrices[mname]['dur'][self.source, h]
|
| 593 |
+
sh_cost = self.calc_cost(distSH, durSH, mi, hub_demand)
|
| 594 |
+
total_cost += sh_cost
|
| 595 |
+
cost_breakdown['S_to_H'] += sh_cost - mi.fixed_cost * hub_demand
|
| 596 |
+
cost_breakdown['fixed'] += mi.fixed_cost * hub_demand
|
| 597 |
+
|
| 598 |
+
# Hub->Node costs
|
| 599 |
+
for n in hub_nodes:
|
| 600 |
+
distHN = self.matrices['bike']['dist'][h, n]
|
| 601 |
+
durHN = self.matrices['bike']['dur'][h, n]
|
| 602 |
+
b = self.modes['bike']
|
| 603 |
+
hn_cost = self.calc_cost(distHN, durHN, b, self.demand[n])
|
| 604 |
+
total_cost += hn_cost
|
| 605 |
+
cost_breakdown['H_to_N'] += hn_cost - b.fixed_cost * self.demand[n]
|
| 606 |
+
cost_breakdown['fixed'] += b.fixed_cost * self.demand[n]
|
| 607 |
+
|
| 608 |
+
return hubs, allocations, total_cost, sh_modes, p_eff, cost_breakdown
|
requirements.txt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core Streamlit and Data Processing
|
| 2 |
+
streamlit>=1.28.0
|
| 3 |
+
pandas>=2.0.0
|
| 4 |
+
numpy>=1.24.0
|
| 5 |
+
openpyxl>=3.1.0
|
| 6 |
+
|
| 7 |
+
# Visualization
|
| 8 |
+
folium>=0.14.0
|
| 9 |
+
matplotlib>=3.7.0
|
| 10 |
+
|
| 11 |
+
# HTTP requests for OSRM routing
|
| 12 |
+
requests>=2.31.0
|
| 13 |
+
|
| 14 |
+
# Optional: If you need these, uncomment
|
| 15 |
+
# scipy>=1.10.0
|
| 16 |
+
# scikit-learn>=1.3.0
|
tsp_optimizer.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
TSP optimization using DOMINO algorithm
|
| 3 |
+
Complete implementation converted from MATLAB
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
from typing import Tuple, List
|
| 8 |
+
|
| 9 |
+
class Tile:
|
| 10 |
+
"""Solution container for TSP optimization"""
|
| 11 |
+
def __init__(self):
|
| 12 |
+
self.Position = None # Tour array
|
| 13 |
+
self.Cost = np.inf # Tour cost
|
| 14 |
+
|
| 15 |
+
def Cost_tsp(tour: np.ndarray, D: np.ndarray) -> float:
|
| 16 |
+
"""
|
| 17 |
+
Calculate TSP tour cost.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
tour: Array of node indices in tour order
|
| 21 |
+
D: Distance matrix (n x n)
|
| 22 |
+
|
| 23 |
+
Returns:
|
| 24 |
+
Total tour cost (sum of edge costs including return to start)
|
| 25 |
+
"""
|
| 26 |
+
n = len(tour)
|
| 27 |
+
cost = 0.0
|
| 28 |
+
|
| 29 |
+
# Sum edges in tour
|
| 30 |
+
for i in range(n - 1):
|
| 31 |
+
cost += D[tour[i], tour[i + 1]]
|
| 32 |
+
|
| 33 |
+
# Return to start
|
| 34 |
+
cost += D[tour[n - 1], tour[0]]
|
| 35 |
+
|
| 36 |
+
return cost
|
| 37 |
+
|
| 38 |
+
def DOMINO_CentroidOperator(Pos: np.ndarray, D: np.ndarray, removeRate: float) -> Tuple[np.ndarray, float]:
|
| 39 |
+
"""
|
| 40 |
+
DOMINO Centroid Operator for TSP optimization.
|
| 41 |
+
|
| 42 |
+
Removes a fraction of nodes from the tour and reinserts them
|
| 43 |
+
in random order to create a new solution.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
Pos: Current position (tour array)
|
| 47 |
+
D: Distance matrix
|
| 48 |
+
removeRate: Fraction of nodes to remove and reinsert (0.0 to 1.0)
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
Tuple of (new_position, new_cost)
|
| 52 |
+
"""
|
| 53 |
+
n = len(Pos)
|
| 54 |
+
remove_num = max(1, round(removeRate * n))
|
| 55 |
+
|
| 56 |
+
# Randomly select nodes to remove
|
| 57 |
+
remove_idx = np.sort(np.random.choice(n, remove_num, replace=False))
|
| 58 |
+
removed = [Pos[i] for i in remove_idx]
|
| 59 |
+
remaining = [Pos[i] for i in range(n) if i not in remove_idx]
|
| 60 |
+
|
| 61 |
+
# Reinsert removed nodes randomly
|
| 62 |
+
newPos = np.zeros(n, dtype=int)
|
| 63 |
+
newPos[:len(remaining)] = remaining
|
| 64 |
+
|
| 65 |
+
# Shuffle removed nodes and place at end
|
| 66 |
+
shuffled_removed = np.random.permutation(removed)
|
| 67 |
+
newPos[len(remaining):] = shuffled_removed
|
| 68 |
+
|
| 69 |
+
newCost = Cost_tsp(newPos, D)
|
| 70 |
+
|
| 71 |
+
return newPos, newCost
|
| 72 |
+
|
| 73 |
+
def swap_route(route: np.ndarray) -> np.ndarray:
|
| 74 |
+
"""
|
| 75 |
+
Swap two random positions in route.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
route: Current route
|
| 79 |
+
|
| 80 |
+
Returns:
|
| 81 |
+
Route with two positions swapped
|
| 82 |
+
"""
|
| 83 |
+
route_new = route.copy()
|
| 84 |
+
if len(route_new) < 2:
|
| 85 |
+
return route_new
|
| 86 |
+
|
| 87 |
+
idx = np.random.choice(len(route_new), 2, replace=False)
|
| 88 |
+
route_new[idx[0]], route_new[idx[1]] = route_new[idx[1]], route_new[idx[0]]
|
| 89 |
+
|
| 90 |
+
return route_new
|
| 91 |
+
|
| 92 |
+
def reversion_route(route: np.ndarray) -> np.ndarray:
|
| 93 |
+
"""
|
| 94 |
+
Reverse a random segment of route.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
route: Current route
|
| 98 |
+
|
| 99 |
+
Returns:
|
| 100 |
+
Route with segment reversed
|
| 101 |
+
"""
|
| 102 |
+
route_new = route.copy()
|
| 103 |
+
if len(route_new) < 2:
|
| 104 |
+
return route_new
|
| 105 |
+
|
| 106 |
+
idx = np.sort(np.random.choice(len(route_new), 2, replace=False))
|
| 107 |
+
route_new[idx[0]:idx[1]+1] = route_new[idx[0]:idx[1]+1][::-1]
|
| 108 |
+
|
| 109 |
+
return route_new
|
| 110 |
+
|
| 111 |
+
def insertion_route(route: np.ndarray) -> np.ndarray:
|
| 112 |
+
"""
|
| 113 |
+
Remove one element and insert at random position.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
route: Current route
|
| 117 |
+
|
| 118 |
+
Returns:
|
| 119 |
+
Route with element moved
|
| 120 |
+
"""
|
| 121 |
+
route_new = route.copy()
|
| 122 |
+
if len(route_new) < 2:
|
| 123 |
+
return route_new
|
| 124 |
+
|
| 125 |
+
idx = np.random.choice(len(route_new), 2, replace=False)
|
| 126 |
+
i1, i2 = idx[0], idx[1]
|
| 127 |
+
|
| 128 |
+
elem = route_new[i1]
|
| 129 |
+
route_new = np.delete(route_new, i1)
|
| 130 |
+
|
| 131 |
+
if i2 > len(route_new):
|
| 132 |
+
route_new = np.append(route_new, elem)
|
| 133 |
+
else:
|
| 134 |
+
route_new = np.insert(route_new, i2, elem)
|
| 135 |
+
|
| 136 |
+
return route_new
|
| 137 |
+
|
| 138 |
+
def optimize_tsp_domino(D_tsp: np.ndarray, max_it: int = 50, rounds: int = 5,
|
| 139 |
+
removeRate: float = 0.2) -> Tuple[Tile, np.ndarray, List]:
|
| 140 |
+
"""
|
| 141 |
+
Optimize TSP using DOMINO algorithm.
|
| 142 |
+
|
| 143 |
+
The DOMINO algorithm maintains multiple parallel solutions (rounds) and
|
| 144 |
+
applies the centroid operator to explore the search space.
|
| 145 |
+
|
| 146 |
+
Args:
|
| 147 |
+
D_tsp: Distance matrix (n x n)
|
| 148 |
+
max_it: Maximum iterations (default 50)
|
| 149 |
+
rounds: Number of parallel solutions (default 5)
|
| 150 |
+
removeRate: Fraction of tour to remove/reinsert (default 0.2)
|
| 151 |
+
|
| 152 |
+
Returns:
|
| 153 |
+
Tuple of:
|
| 154 |
+
- BestSol: Best solution found (Tile object)
|
| 155 |
+
- BestCost: Array of best costs per iteration
|
| 156 |
+
- BestPos: List of best positions per iteration
|
| 157 |
+
"""
|
| 158 |
+
n_tsp = D_tsp.shape[0]
|
| 159 |
+
|
| 160 |
+
# Initialize tiles (parallel solutions)
|
| 161 |
+
tile = [Tile() for _ in range(rounds)]
|
| 162 |
+
for i in range(rounds):
|
| 163 |
+
tour = np.random.permutation(n_tsp)
|
| 164 |
+
tile[i].Position = tour
|
| 165 |
+
tile[i].Cost = Cost_tsp(tour, D_tsp)
|
| 166 |
+
|
| 167 |
+
BestCost_tsp = np.zeros(max_it)
|
| 168 |
+
BestPos_tsp = [None] * max_it
|
| 169 |
+
|
| 170 |
+
# Main optimization loop
|
| 171 |
+
for it in range(max_it):
|
| 172 |
+
# Update each solution
|
| 173 |
+
for i in range(rounds):
|
| 174 |
+
bestnew = Tile()
|
| 175 |
+
bestnew.Position = None
|
| 176 |
+
bestnew.Cost = np.inf
|
| 177 |
+
|
| 178 |
+
# Try multiple neighborhood moves
|
| 179 |
+
for j in range(rounds):
|
| 180 |
+
newPos, newCost = DOMINO_CentroidOperator(tile[i].Position, D_tsp, removeRate)
|
| 181 |
+
if newCost < bestnew.Cost:
|
| 182 |
+
bestnew.Position = newPos
|
| 183 |
+
bestnew.Cost = newCost
|
| 184 |
+
|
| 185 |
+
# Accept improvement
|
| 186 |
+
if bestnew.Cost < tile[i].Cost:
|
| 187 |
+
tile[i] = bestnew
|
| 188 |
+
|
| 189 |
+
# Sort tiles by cost
|
| 190 |
+
tile.sort(key=lambda x: x.Cost)
|
| 191 |
+
BestSol_tsp = tile[0]
|
| 192 |
+
BestCost_tsp[it] = BestSol_tsp.Cost
|
| 193 |
+
BestPos_tsp[it] = BestSol_tsp.Position.copy()
|
| 194 |
+
|
| 195 |
+
print(f'Iteration {it+1}: Best Cost = {BestCost_tsp[it]:.2f} units')
|
| 196 |
+
|
| 197 |
+
return BestSol_tsp, BestCost_tsp, BestPos_tsp
|
utils.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utility functions for data processing and conversion
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import re
|
| 8 |
+
|
| 9 |
+
def local_to_numeric_matrix(cell_mat):
|
| 10 |
+
"""Convert cell array/DataFrame to numeric matrix"""
|
| 11 |
+
if isinstance(cell_mat, np.ndarray):
|
| 12 |
+
if len(cell_mat) == 0:
|
| 13 |
+
return np.array([])
|
| 14 |
+
if np.issubdtype(cell_mat.dtype, np.number):
|
| 15 |
+
return cell_mat.astype(float)
|
| 16 |
+
|
| 17 |
+
# Convert to DataFrame if not already
|
| 18 |
+
if not isinstance(cell_mat, pd.DataFrame):
|
| 19 |
+
cell_mat = pd.DataFrame(cell_mat)
|
| 20 |
+
|
| 21 |
+
mat = np.zeros(cell_mat.shape)
|
| 22 |
+
for i in range(cell_mat.shape[0]):
|
| 23 |
+
for j in range(cell_mat.shape[1]):
|
| 24 |
+
mat[i, j] = _coerce_to_double(cell_mat.iloc[i, j])
|
| 25 |
+
|
| 26 |
+
return mat
|
| 27 |
+
|
| 28 |
+
def local_to_numeric_vector(col):
|
| 29 |
+
"""Convert column/row of labels to numeric vector"""
|
| 30 |
+
if isinstance(col, (list, np.ndarray, pd.Series)):
|
| 31 |
+
if len(col) == 0:
|
| 32 |
+
return np.array([])
|
| 33 |
+
|
| 34 |
+
vec = np.zeros(len(col))
|
| 35 |
+
for i, val in enumerate(col):
|
| 36 |
+
vec[i] = _coerce_to_double(val)
|
| 37 |
+
return vec
|
| 38 |
+
|
| 39 |
+
return np.array([_coerce_to_double(col)])
|
| 40 |
+
|
| 41 |
+
def local_parse_route(route_str):
|
| 42 |
+
"""Parse route string to list of integers"""
|
| 43 |
+
if isinstance(route_str, (list, np.ndarray)):
|
| 44 |
+
return list(route_str)
|
| 45 |
+
|
| 46 |
+
if isinstance(route_str, (int, float)):
|
| 47 |
+
return [int(route_str)]
|
| 48 |
+
|
| 49 |
+
if isinstance(route_str, str):
|
| 50 |
+
# Extract all numbers from string
|
| 51 |
+
numbers = re.findall(r'\d+', route_str)
|
| 52 |
+
return [int(n) for n in numbers]
|
| 53 |
+
|
| 54 |
+
return []
|
| 55 |
+
|
| 56 |
+
def local_find_coord_columns(T):
|
| 57 |
+
"""Find longitude, latitude, and id column indices"""
|
| 58 |
+
cols = [c.lower() for c in T.columns]
|
| 59 |
+
|
| 60 |
+
lonIdx = next((i for i, c in enumerate(cols) if any(x in c for x in ['lon', 'x', 'longitude'])), 0)
|
| 61 |
+
latIdx = next((i for i, c in enumerate(cols) if any(x in c for x in ['lat', 'y', 'latitude'])), 1)
|
| 62 |
+
idIdx = next((i for i, c in enumerate(cols) if any(x in c for x in ['id', 'node'])), 2)
|
| 63 |
+
|
| 64 |
+
return lonIdx, latIdx, idIdx
|
| 65 |
+
|
| 66 |
+
def _coerce_to_double(x):
|
| 67 |
+
"""Convert single value to float with safe rules"""
|
| 68 |
+
if isinstance(x, (int, float, np.number)):
|
| 69 |
+
return float(x)
|
| 70 |
+
|
| 71 |
+
if isinstance(x, str):
|
| 72 |
+
s = x.strip()
|
| 73 |
+
if s == "" or s == "-" or s == "—" or s.upper() in ["NA", "N/A"]:
|
| 74 |
+
return np.nan
|
| 75 |
+
# Extract numeric part
|
| 76 |
+
s = re.sub(r'[^\d\.\-eE]', '', s)
|
| 77 |
+
try:
|
| 78 |
+
return float(s)
|
| 79 |
+
except ValueError:
|
| 80 |
+
return np.nan
|
| 81 |
+
|
| 82 |
+
if pd.isna(x):
|
| 83 |
+
return np.nan
|
| 84 |
+
|
| 85 |
+
return np.nan
|
| 86 |
+
|
| 87 |
+
def Cost_tsp(tour, D):
|
| 88 |
+
"""Calculate TSP tour cost"""
|
| 89 |
+
n = len(tour)
|
| 90 |
+
cost = 0.0
|
| 91 |
+
for i in range(n - 1):
|
| 92 |
+
cost += D[tour[i], tour[i + 1]]
|
| 93 |
+
cost += D[tour[n - 1], tour[0]]
|
| 94 |
+
return cost
|
vehicle_routing.py
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Vehicle routing and cost calculation functions
|
| 3 |
+
Complete implementation converted from MATLAB
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
from typing import Dict, List, Tuple, Any
|
| 8 |
+
|
| 9 |
+
def split_vehicle_routes(route: np.ndarray, vehicle_ids: List) -> List[List]:
|
| 10 |
+
"""
|
| 11 |
+
Split route into segments by vehicle markers.
|
| 12 |
+
|
| 13 |
+
A vehicle marker in the route indicates the start of a new segment
|
| 14 |
+
for that vehicle. Example: [150, 5, 7, 151, 9, 12] becomes two segments:
|
| 15 |
+
[150, 5, 7] and [151, 9, 12].
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
route: Array of node/vehicle IDs
|
| 19 |
+
vehicle_ids: List of vehicle ID markers (e.g., [150, 151, 250])
|
| 20 |
+
|
| 21 |
+
Returns:
|
| 22 |
+
List of route segments, each starting with a vehicle ID
|
| 23 |
+
"""
|
| 24 |
+
vehset = np.isin(route, vehicle_ids)
|
| 25 |
+
routes = []
|
| 26 |
+
cur = []
|
| 27 |
+
|
| 28 |
+
for k in range(len(route)):
|
| 29 |
+
node = route[k]
|
| 30 |
+
if vehset[k]:
|
| 31 |
+
# Start of new vehicle segment
|
| 32 |
+
if len(cur) > 1:
|
| 33 |
+
routes.append(cur)
|
| 34 |
+
cur = [node]
|
| 35 |
+
else:
|
| 36 |
+
# Customer node
|
| 37 |
+
if len(cur) == 0:
|
| 38 |
+
cur = [node]
|
| 39 |
+
else:
|
| 40 |
+
cur.append(node)
|
| 41 |
+
|
| 42 |
+
# Add last segment if valid
|
| 43 |
+
if len(cur) > 1:
|
| 44 |
+
routes.append(cur)
|
| 45 |
+
|
| 46 |
+
return routes
|
| 47 |
+
|
| 48 |
+
def calculate_total_cost_mixed_fleet(depots: Dict, route: np.ndarray,
|
| 49 |
+
DistMat_dict: Dict, DurMat_dict: Dict,
|
| 50 |
+
demandMap: Dict, vmap: Dict, id2idx: Dict,
|
| 51 |
+
service_time_min: float, penalty_fee: float,
|
| 52 |
+
vehicle_ids: np.ndarray, weights: Dict,
|
| 53 |
+
Max250: int) -> Tuple[float, float, np.ndarray, np.ndarray, np.ndarray, np.ndarray, float]:
|
| 54 |
+
"""
|
| 55 |
+
Calculate total cost for mixed fleet routing.
|
| 56 |
+
|
| 57 |
+
Computes weighted objective including fixed, variable, labour, emission costs
|
| 58 |
+
and penalties for time/capacity violations and unserved customers.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
depots: Dict mapping vehicle_id -> depot_label (e.g., {150: 101, 151: 102, 250: 101})
|
| 62 |
+
route: Array of route (vehicle IDs + customer labels)
|
| 63 |
+
DistMat_dict: Dict of distance matrices per vehicle {vid: matrix}
|
| 64 |
+
DurMat_dict: Dict of duration matrices per vehicle {vid: matrix}
|
| 65 |
+
demandMap: Dict mapping customer_label -> demand
|
| 66 |
+
vmap: Dict mapping vehicle_id -> vehicle params dict
|
| 67 |
+
id2idx: Dict mapping label -> matrix index
|
| 68 |
+
service_time_min: Service time per customer (minutes)
|
| 69 |
+
penalty_fee: Penalty coefficient (£)
|
| 70 |
+
vehicle_ids: Array of vehicle IDs
|
| 71 |
+
weights: Dict with 'fix', 'var', 'lab', 'emis' weights
|
| 72 |
+
Max250: Max number of vehicle 250 allowed
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
Tuple of (total_cost, total_time, travel_times, time_penalties,
|
| 76 |
+
cap_penalties, capacities, total_emissions)
|
| 77 |
+
"""
|
| 78 |
+
total_variable_cost = 0.0
|
| 79 |
+
total_labour_cost = 0.0
|
| 80 |
+
total_emission_cost = 0.0
|
| 81 |
+
total_fixed_cost = 0.0
|
| 82 |
+
total_time_penalty = 0.0
|
| 83 |
+
total_capacity_penalty = 0.0
|
| 84 |
+
total_emissions = 0.0
|
| 85 |
+
total_time = 0.0
|
| 86 |
+
|
| 87 |
+
travel_times = []
|
| 88 |
+
time_penalties = []
|
| 89 |
+
cap_penalties = []
|
| 90 |
+
capacities = []
|
| 91 |
+
|
| 92 |
+
# Global 250-cap penalty
|
| 93 |
+
count250 = np.sum(route == 250)
|
| 94 |
+
if count250 > Max250:
|
| 95 |
+
over = count250 - Max250
|
| 96 |
+
total_time_penalty += penalty_fee * (5000 + 100 * over)
|
| 97 |
+
|
| 98 |
+
# Split route into vehicle segments
|
| 99 |
+
routes = split_vehicle_routes(route, vehicle_ids)
|
| 100 |
+
|
| 101 |
+
for vr in routes:
|
| 102 |
+
if not vr:
|
| 103 |
+
continue
|
| 104 |
+
|
| 105 |
+
vid = int(vr[0])
|
| 106 |
+
if vid not in vmap:
|
| 107 |
+
continue
|
| 108 |
+
|
| 109 |
+
params = vmap[vid]
|
| 110 |
+
base = depots[vid]
|
| 111 |
+
DM = DistMat_dict[vid]
|
| 112 |
+
TM = DurMat_dict[vid]
|
| 113 |
+
|
| 114 |
+
# 250 single-customer constraint
|
| 115 |
+
if vid == 250:
|
| 116 |
+
num_customers_250 = sum(1 for x in vr[1:] if x not in vehicle_ids)
|
| 117 |
+
if num_customers_250 > 1:
|
| 118 |
+
over = num_customers_250 - 1
|
| 119 |
+
total_time_penalty += penalty_fee * (1000 + over)
|
| 120 |
+
|
| 121 |
+
travel_time = 0.0
|
| 122 |
+
travel_dist = 0.0
|
| 123 |
+
cap = 0.0
|
| 124 |
+
|
| 125 |
+
# Calculate travel along route
|
| 126 |
+
for i in range(len(vr) - 1):
|
| 127 |
+
u = int(vr[i])
|
| 128 |
+
v = int(vr[i + 1])
|
| 129 |
+
|
| 130 |
+
if u not in id2idx or v not in id2idx:
|
| 131 |
+
continue
|
| 132 |
+
|
| 133 |
+
u_idx = id2idx[u]
|
| 134 |
+
v_idx = id2idx[v]
|
| 135 |
+
dij = DM[u_idx, v_idx]
|
| 136 |
+
tij = TM[u_idx, v_idx]
|
| 137 |
+
|
| 138 |
+
travel_dist += dij
|
| 139 |
+
travel_time += tij
|
| 140 |
+
|
| 141 |
+
# Add service time if next node is customer
|
| 142 |
+
if v not in vehicle_ids:
|
| 143 |
+
travel_time += service_time_min
|
| 144 |
+
if v in demandMap:
|
| 145 |
+
cap += demandMap[v]
|
| 146 |
+
|
| 147 |
+
# Accumulate costs
|
| 148 |
+
total_variable_cost += params['var_cost'] * dij
|
| 149 |
+
total_labour_cost += params['time_cost'] * (tij / 60.0)
|
| 150 |
+
total_emission_cost += (params['emission_rate'] * params['emission_factor']) * dij
|
| 151 |
+
total_emissions += params['emission_factor'] * dij
|
| 152 |
+
|
| 153 |
+
# Return to base if last node is customer
|
| 154 |
+
last = int(vr[-1])
|
| 155 |
+
if last not in vehicle_ids:
|
| 156 |
+
if last in id2idx and int(base) in id2idx:
|
| 157 |
+
last_idx = id2idx[last]
|
| 158 |
+
base_idx = id2idx[int(base)]
|
| 159 |
+
dij = DM[last_idx, base_idx]
|
| 160 |
+
tij = TM[last_idx, base_idx]
|
| 161 |
+
|
| 162 |
+
travel_dist += dij
|
| 163 |
+
travel_time += tij
|
| 164 |
+
total_variable_cost += params['var_cost'] * dij
|
| 165 |
+
total_labour_cost += params['time_cost'] * (tij / 60.0)
|
| 166 |
+
total_emission_cost += (params['emission_rate'] * params['emission_factor']) * dij
|
| 167 |
+
total_emissions += params['emission_factor'] * dij
|
| 168 |
+
|
| 169 |
+
# Fixed cost per vehicle
|
| 170 |
+
total_fixed_cost += params['fixed_cost']
|
| 171 |
+
|
| 172 |
+
# Penalties for constraint violations
|
| 173 |
+
tpen = 0.0
|
| 174 |
+
cpen = 0.0
|
| 175 |
+
if travel_time > params['MaxTime']:
|
| 176 |
+
tpen = penalty_fee * (travel_time - params['MaxTime'])
|
| 177 |
+
if cap > params['capacity']:
|
| 178 |
+
cpen = penalty_fee * (cap - params['capacity'])
|
| 179 |
+
|
| 180 |
+
travel_times.append(travel_time)
|
| 181 |
+
time_penalties.append(tpen)
|
| 182 |
+
cap_penalties.append(cpen)
|
| 183 |
+
capacities.append(cap)
|
| 184 |
+
|
| 185 |
+
total_time += travel_time
|
| 186 |
+
total_time_penalty += tpen
|
| 187 |
+
total_capacity_penalty += cpen
|
| 188 |
+
|
| 189 |
+
# Penalty for unserved customers
|
| 190 |
+
served = set()
|
| 191 |
+
for node in route:
|
| 192 |
+
nid = int(node)
|
| 193 |
+
if nid in demandMap and demandMap[nid] > 0:
|
| 194 |
+
served.add(nid)
|
| 195 |
+
|
| 196 |
+
positive_cust = [k for k, v in demandMap.items() if v > 0]
|
| 197 |
+
missing = set(positive_cust) - served
|
| 198 |
+
if missing:
|
| 199 |
+
total_time_penalty += penalty_fee * len(missing)
|
| 200 |
+
|
| 201 |
+
# Weighted objective
|
| 202 |
+
total_cost = (weights['fix'] * total_fixed_cost +
|
| 203 |
+
weights['var'] * total_variable_cost +
|
| 204 |
+
weights['lab'] * total_labour_cost +
|
| 205 |
+
weights['emis'] * total_emission_cost +
|
| 206 |
+
total_time_penalty + total_capacity_penalty)
|
| 207 |
+
|
| 208 |
+
return (total_cost, total_time, np.array(travel_times),
|
| 209 |
+
np.array(time_penalties), np.array(cap_penalties),
|
| 210 |
+
np.array(capacities), total_emissions)
|
| 211 |
+
|
| 212 |
+
def foraging_combi(route: np.ndarray, Vehicle_ids: List) -> np.ndarray:
|
| 213 |
+
"""
|
| 214 |
+
Apply random operator from set of route operators.
|
| 215 |
+
|
| 216 |
+
Randomly selects and applies one of: swap, reversion, insertion, or depot mutation.
|
| 217 |
+
|
| 218 |
+
Args:
|
| 219 |
+
route: Current route
|
| 220 |
+
Vehicle_ids: List of vehicle IDs
|
| 221 |
+
|
| 222 |
+
Returns:
|
| 223 |
+
Modified route
|
| 224 |
+
"""
|
| 225 |
+
ops = [
|
| 226 |
+
lambda r: swap_route(r),
|
| 227 |
+
lambda r: reversion_route(r),
|
| 228 |
+
lambda r: insertion_route(r),
|
| 229 |
+
lambda r: depot_mutation(r, Vehicle_ids)
|
| 230 |
+
]
|
| 231 |
+
op = np.random.choice(ops)
|
| 232 |
+
return op(route)
|
| 233 |
+
|
| 234 |
+
def depot_mutation(route: np.ndarray, Vehicle_ids: List) -> np.ndarray:
|
| 235 |
+
"""
|
| 236 |
+
Mutate depot/vehicle assignments.
|
| 237 |
+
|
| 238 |
+
Randomly changes a vehicle marker to a different vehicle or removes it.
|
| 239 |
+
|
| 240 |
+
Args:
|
| 241 |
+
route: Current route
|
| 242 |
+
Vehicle_ids: List of vehicle IDs
|
| 243 |
+
|
| 244 |
+
Returns:
|
| 245 |
+
Modified route
|
| 246 |
+
"""
|
| 247 |
+
route_new = route.copy()
|
| 248 |
+
depot_idx = np.where(np.isin(route_new, Vehicle_ids))[0]
|
| 249 |
+
|
| 250 |
+
if len(depot_idx) > 0:
|
| 251 |
+
idx = np.random.choice(depot_idx)
|
| 252 |
+
if np.random.rand() < 0.5:
|
| 253 |
+
# Change to different vehicle
|
| 254 |
+
choices = [v for v in Vehicle_ids if v != route_new[idx]]
|
| 255 |
+
if choices:
|
| 256 |
+
route_new[idx] = np.random.choice(choices)
|
| 257 |
+
else:
|
| 258 |
+
# Remove depot marker (if not at boundaries)
|
| 259 |
+
if idx > 0 and idx < len(route_new) - 1:
|
| 260 |
+
route_new = np.delete(route_new, idx)
|
| 261 |
+
|
| 262 |
+
return route_new
|
| 263 |
+
|
| 264 |
+
def swap_route(route: np.ndarray) -> np.ndarray:
|
| 265 |
+
"""
|
| 266 |
+
Swap two random positions in route.
|
| 267 |
+
|
| 268 |
+
Args:
|
| 269 |
+
route: Current route
|
| 270 |
+
|
| 271 |
+
Returns:
|
| 272 |
+
Route with two positions swapped
|
| 273 |
+
"""
|
| 274 |
+
route_new = route.copy()
|
| 275 |
+
if len(route_new) < 2:
|
| 276 |
+
return route_new
|
| 277 |
+
|
| 278 |
+
idx = np.random.choice(len(route_new), 2, replace=False)
|
| 279 |
+
route_new[idx[0]], route_new[idx[1]] = route_new[idx[1]], route_new[idx[0]]
|
| 280 |
+
|
| 281 |
+
return route_new
|
| 282 |
+
|
| 283 |
+
def reversion_route(route: np.ndarray) -> np.ndarray:
|
| 284 |
+
"""
|
| 285 |
+
Reverse random segment of route.
|
| 286 |
+
|
| 287 |
+
Args:
|
| 288 |
+
route: Current route
|
| 289 |
+
|
| 290 |
+
Returns:
|
| 291 |
+
Route with segment reversed
|
| 292 |
+
"""
|
| 293 |
+
route_new = route.copy()
|
| 294 |
+
if len(route_new) < 2:
|
| 295 |
+
return route_new
|
| 296 |
+
|
| 297 |
+
idx = np.sort(np.random.choice(len(route_new), 2, replace=False))
|
| 298 |
+
route_new[idx[0]:idx[1]+1] = route_new[idx[0]:idx[1]+1][::-1]
|
| 299 |
+
|
| 300 |
+
return route_new
|
| 301 |
+
|
| 302 |
+
def insertion_route(route: np.ndarray) -> np.ndarray:
|
| 303 |
+
"""
|
| 304 |
+
Remove and reinsert element at random position.
|
| 305 |
+
|
| 306 |
+
Args:
|
| 307 |
+
route: Current route
|
| 308 |
+
|
| 309 |
+
Returns:
|
| 310 |
+
Route with element moved
|
| 311 |
+
"""
|
| 312 |
+
route_new = route.copy()
|
| 313 |
+
if len(route_new) < 2:
|
| 314 |
+
return route_new
|
| 315 |
+
|
| 316 |
+
idx = np.random.choice(len(route_new), 2, replace=False)
|
| 317 |
+
i1, i2 = idx[0], idx[1]
|
| 318 |
+
|
| 319 |
+
elem = route_new[i1]
|
| 320 |
+
route_new = np.delete(route_new, i1)
|
| 321 |
+
|
| 322 |
+
if i2 > len(route_new):
|
| 323 |
+
route_new = np.append(route_new, elem)
|
| 324 |
+
else:
|
| 325 |
+
route_new = np.insert(route_new, i2, elem)
|
| 326 |
+
|
| 327 |
+
return route_new
|
| 328 |
+
|
| 329 |
+
def penalty_splitting_operator(route: np.ndarray, DurMat_dict: Dict, demandMap: Dict,
|
| 330 |
+
vmap: Dict, depots: Dict, DistMat_dict: Dict,
|
| 331 |
+
id2idx: Dict, vehicle_ids: np.ndarray,
|
| 332 |
+
service_time_min: float, repeat_split: bool) -> np.ndarray:
|
| 333 |
+
"""
|
| 334 |
+
Split overlong vehicle segments by inserting additional vehicle marker.
|
| 335 |
+
|
| 336 |
+
Identifies segments that exceed 1.2× their vehicle's time limit and splits
|
| 337 |
+
them by inserting a new vehicle marker near the midpoint customer.
|
| 338 |
+
|
| 339 |
+
Args:
|
| 340 |
+
route: Current route
|
| 341 |
+
DurMat_dict: Duration matrices per vehicle
|
| 342 |
+
demandMap: Demand map
|
| 343 |
+
vmap: Vehicle parameters
|
| 344 |
+
depots: Depot assignments
|
| 345 |
+
DistMat_dict: Distance matrices per vehicle
|
| 346 |
+
id2idx: Label to index mapping
|
| 347 |
+
vehicle_ids: Vehicle IDs
|
| 348 |
+
service_time_min: Service time per customer
|
| 349 |
+
repeat_split: Whether to repeat splitting until all segments are valid
|
| 350 |
+
|
| 351 |
+
Returns:
|
| 352 |
+
Modified route with split segments
|
| 353 |
+
"""
|
| 354 |
+
vehs = vehicle_ids
|
| 355 |
+
new_route = route.copy()
|
| 356 |
+
|
| 357 |
+
def segment_time_with_return(vr: List, vid: int) -> float:
|
| 358 |
+
"""Calculate segment time including return to base."""
|
| 359 |
+
TM = DurMat_dict[vid]
|
| 360 |
+
base = depots[vid]
|
| 361 |
+
tmin = 0.0
|
| 362 |
+
|
| 363 |
+
for i in range(len(vr) - 1):
|
| 364 |
+
u = int(vr[i])
|
| 365 |
+
v = int(vr[i + 1])
|
| 366 |
+
if u not in id2idx or v not in id2idx:
|
| 367 |
+
continue
|
| 368 |
+
ui = id2idx[u]
|
| 369 |
+
vi = id2idx[v]
|
| 370 |
+
tmin += TM[ui, vi]
|
| 371 |
+
|
| 372 |
+
if v not in vehs:
|
| 373 |
+
tmin += service_time_min
|
| 374 |
+
|
| 375 |
+
# Return to base
|
| 376 |
+
last = int(vr[-1])
|
| 377 |
+
if last not in vehs:
|
| 378 |
+
if last in id2idx and int(base) in id2idx:
|
| 379 |
+
li = id2idx[last]
|
| 380 |
+
bi = id2idx[int(base)]
|
| 381 |
+
tmin += TM[li, bi]
|
| 382 |
+
|
| 383 |
+
return tmin
|
| 384 |
+
|
| 385 |
+
def collapse_consecutive_vehicles(r1: np.ndarray, vehset: List) -> np.ndarray:
|
| 386 |
+
"""Remove duplicate adjacent vehicle markers."""
|
| 387 |
+
if len(r1) == 0:
|
| 388 |
+
return r1
|
| 389 |
+
|
| 390 |
+
keep = np.ones(len(r1), dtype=bool)
|
| 391 |
+
for k in range(1, len(r1)):
|
| 392 |
+
if r1[k] in vehset and r1[k-1] in vehset and r1[k] == r1[k-1]:
|
| 393 |
+
keep[k] = False
|
| 394 |
+
|
| 395 |
+
return r1[keep]
|
| 396 |
+
|
| 397 |
+
def do_one_pass(r_in: np.ndarray) -> np.ndarray:
|
| 398 |
+
"""Perform one pass of splitting overlong segments."""
|
| 399 |
+
# Split into segments
|
| 400 |
+
segs = []
|
| 401 |
+
cur = []
|
| 402 |
+
for x in r_in:
|
| 403 |
+
if x in vehs:
|
| 404 |
+
if len(cur) > 1:
|
| 405 |
+
segs.append(cur)
|
| 406 |
+
cur = [x]
|
| 407 |
+
else:
|
| 408 |
+
if len(cur) == 0:
|
| 409 |
+
cur = [x]
|
| 410 |
+
else:
|
| 411 |
+
cur.append(x)
|
| 412 |
+
if len(cur) > 1:
|
| 413 |
+
segs.append(cur)
|
| 414 |
+
|
| 415 |
+
if not segs:
|
| 416 |
+
return r_in
|
| 417 |
+
|
| 418 |
+
# Find overlong segments
|
| 419 |
+
is_long = []
|
| 420 |
+
for vr in segs:
|
| 421 |
+
vid = int(vr[0])
|
| 422 |
+
if vid not in vmap:
|
| 423 |
+
is_long.append(False)
|
| 424 |
+
continue
|
| 425 |
+
tmin = segment_time_with_return(vr, vid)
|
| 426 |
+
is_long.append(tmin > 1.2 * vmap[vid]['MaxTime'])
|
| 427 |
+
|
| 428 |
+
if not any(is_long):
|
| 429 |
+
return r_in
|
| 430 |
+
|
| 431 |
+
# Process long segments
|
| 432 |
+
for si, long in enumerate(is_long):
|
| 433 |
+
if not long:
|
| 434 |
+
continue
|
| 435 |
+
|
| 436 |
+
vr = segs[si]
|
| 437 |
+
if len(vr) <= 3:
|
| 438 |
+
continue
|
| 439 |
+
|
| 440 |
+
# Find midpoint customer
|
| 441 |
+
mid = len(vr) // 2
|
| 442 |
+
mid = max(2, min(mid, len(vr) - 1))
|
| 443 |
+
|
| 444 |
+
if vr[mid] in vehs:
|
| 445 |
+
cust_idx = [i for i in range(2, len(vr)) if vr[i] not in vehs]
|
| 446 |
+
if not cust_idx:
|
| 447 |
+
continue
|
| 448 |
+
mid = min(cust_idx, key=lambda i: abs(i - mid))
|
| 449 |
+
|
| 450 |
+
cust_to_split = int(vr[mid])
|
| 451 |
+
if cust_to_split in vehs:
|
| 452 |
+
continue
|
| 453 |
+
|
| 454 |
+
demand_c = demandMap.get(cust_to_split, 0)
|
| 455 |
+
|
| 456 |
+
# Find feasible vehicles
|
| 457 |
+
feasible = [v for v in vehs if vmap[int(v)]['capacity'] >= demand_c]
|
| 458 |
+
if not feasible:
|
| 459 |
+
feasible = vehs
|
| 460 |
+
|
| 461 |
+
# Pick closest depot
|
| 462 |
+
best_vid = int(vr[0])
|
| 463 |
+
best_d = np.inf
|
| 464 |
+
for dv in feasible:
|
| 465 |
+
dv = int(dv)
|
| 466 |
+
DM = DistMat_dict[dv]
|
| 467 |
+
if cust_to_split not in id2idx or depots[dv] not in id2idx:
|
| 468 |
+
continue
|
| 469 |
+
cpos = id2idx[cust_to_split]
|
| 470 |
+
dpos = id2idx[depots[dv]]
|
| 471 |
+
dd = DM[dpos, cpos]
|
| 472 |
+
if dd < best_d:
|
| 473 |
+
best_d = dd
|
| 474 |
+
best_vid = dv
|
| 475 |
+
|
| 476 |
+
# Insert new vehicle marker
|
| 477 |
+
vr_new = list(vr[:mid]) + [float(best_vid)] + list(vr[mid:])
|
| 478 |
+
segs[si] = collapse_consecutive_vehicles(np.array(vr_new), vehs).tolist()
|
| 479 |
+
|
| 480 |
+
# Rebuild route
|
| 481 |
+
r_out = []
|
| 482 |
+
for seg in segs:
|
| 483 |
+
r_out.extend(seg)
|
| 484 |
+
|
| 485 |
+
return collapse_consecutive_vehicles(np.array(r_out), vehs)
|
| 486 |
+
|
| 487 |
+
# One pass
|
| 488 |
+
new_route = do_one_pass(new_route)
|
| 489 |
+
|
| 490 |
+
# Repeat if requested
|
| 491 |
+
if repeat_split:
|
| 492 |
+
max_attempts = 10
|
| 493 |
+
attempt = 0
|
| 494 |
+
prev = None
|
| 495 |
+
while attempt < max_attempts and not np.array_equal(new_route, prev):
|
| 496 |
+
prev = new_route.copy() if new_route is not None else None
|
| 497 |
+
new_route = do_one_pass(new_route)
|
| 498 |
+
attempt += 1
|
| 499 |
+
|
| 500 |
+
return new_route
|
| 501 |
+
|
| 502 |
+
def route_cost_vehicle(route_idx: List, vid: int, DistMat_dict: Dict,
|
| 503 |
+
DurMat_dict: Dict, all_nodes: np.ndarray,
|
| 504 |
+
nodeLabel: np.ndarray, depots: Dict, vmap: Dict,
|
| 505 |
+
service_time_min: float, id2idx: Dict) -> Tuple[float, float, float]:
|
| 506 |
+
"""
|
| 507 |
+
Calculate cost for a single vehicle route.
|
| 508 |
+
|
| 509 |
+
Args:
|
| 510 |
+
route_idx: Route as indices
|
| 511 |
+
vid: Vehicle ID
|
| 512 |
+
DistMat_dict: Distance matrices
|
| 513 |
+
DurMat_dict: Duration matrices
|
| 514 |
+
all_nodes: All node labels
|
| 515 |
+
nodeLabel: Node label mapping
|
| 516 |
+
depots: Depot assignments
|
| 517 |
+
vmap: Vehicle parameters
|
| 518 |
+
service_time_min: Service time
|
| 519 |
+
id2idx: Label to index mapping
|
| 520 |
+
|
| 521 |
+
Returns:
|
| 522 |
+
Tuple of (total_dist_km, total_time_min, total_cost_GBP)
|
| 523 |
+
"""
|
| 524 |
+
DM = DistMat_dict[vid]
|
| 525 |
+
TM = DurMat_dict[vid]
|
| 526 |
+
labels = nodeLabel[route_idx]
|
| 527 |
+
pos = np.array([id2idx[int(lbl)] for lbl in labels])
|
| 528 |
+
|
| 529 |
+
total_dist_km = 0.0
|
| 530 |
+
total_time_min = 0.0
|
| 531 |
+
|
| 532 |
+
for i in range(len(pos) - 1):
|
| 533 |
+
dij = DM[pos[i], pos[i + 1]]
|
| 534 |
+
tij = TM[pos[i], pos[i + 1]]
|
| 535 |
+
total_dist_km += dij
|
| 536 |
+
total_time_min += tij
|
| 537 |
+
|
| 538 |
+
if len(pos) > 2:
|
| 539 |
+
total_time_min += service_time_min * (len(pos) - 2)
|
| 540 |
+
|
| 541 |
+
dist_cost = vmap[vid]['var_cost'] * total_dist_km
|
| 542 |
+
lab_cost = vmap[vid]['time_cost'] * (total_time_min / 60.0)
|
| 543 |
+
total_cost_GBP = vmap[vid]['fixed_cost'] + dist_cost + lab_cost
|
| 544 |
+
|
| 545 |
+
return total_dist_km, total_time_min, total_cost_GBP
|