asrulharunismail commited on
Commit
a6dfdbe
·
verified ·
1 Parent(s): d0b2350

Upload 8 files

Browse files
Files changed (8) hide show
  1. README.md +177 -0
  2. app.py +922 -0
  3. bike_routes.py +190 -0
  4. phub_allocation.py +608 -0
  5. requirements.txt +16 -0
  6. tsp_optimizer.py +197 -0
  7. utils.py +94 -0
  8. 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