|
import streamlit as st |
|
import pandas as pd |
|
import numpy as np |
|
import folium |
|
from streamlit_folium import folium_static |
|
import os |
|
from pathlib import Path |
|
from datetime import datetime, timedelta |
|
import matplotlib.pyplot as plt |
|
import random |
|
import time |
|
from ortools.constraint_solver import routing_enums_pb2 |
|
from ortools.constraint_solver import pywrapcp |
|
import folium.plugins |
|
from folium.features import DivIcon |
|
import requests |
|
import plotly.express as px |
|
|
|
def clear_optimization_results(): |
|
"""Clear optimization results when parameters change""" |
|
if 'optimization_result' in st.session_state: |
|
st.session_state.optimization_result = None |
|
|
|
def optimize_page(): |
|
""" |
|
Render the optimization page with controls for route optimization |
|
""" |
|
st.title("Delivery Route Optimization") |
|
|
|
|
|
with st.expander("π How to Use This Page"): |
|
st.markdown(""" |
|
## Step-by-Step Guide to Route Optimization |
|
|
|
This application helps you optimize delivery routes by assigning deliveries to vehicles in the most efficient way possible. Follow these steps to get started: |
|
|
|
### 1. Set Optimization Parameters (Sidebar) |
|
|
|
- **Select Delivery Dates**: Choose which dates to include in optimization. Select "All" to include all dates. |
|
- **Priority Importance**: Higher values give more weight to high-priority deliveries. |
|
- **Time Window Importance**: Higher values enforce stricter adherence to delivery time windows. |
|
- **Load Balancing vs Distance**: Higher values distribute deliveries more evenly across vehicles. |
|
- **Maximum Vehicles**: Set the maximum number of vehicles to use for deliveries. |
|
- **Minimum Time Window Compliance**: Set the minimum percentage of deliveries that must be within their time windows. |
|
|
|
### 2. Generate Routes |
|
|
|
- Review the delivery statistics and vehicle availability information |
|
- Click the **Generate Optimal Routes** button to run the optimization algorithm |
|
- The algorithm will assign deliveries to vehicles based on your parameters |
|
|
|
### 3. Review Optimization Results |
|
|
|
- **Overall Performance**: Check metrics like assigned deliveries, vehicles used, and time window compliance |
|
- **Time & Distance Distribution**: See how delivery workload is distributed across vehicles |
|
- **Route Map**: Interactive map showing the optimized routes for each vehicle |
|
- Use the date filter to show routes for specific days |
|
- Hover over markers and routes for detailed information |
|
- **Calendar View**: View delivery schedules organized by date |
|
- Green bars indicate on-time deliveries |
|
- Orange bars indicate late deliveries |
|
- Red bars indicate unassigned deliveries |
|
|
|
### 4. Adjust and Refine |
|
|
|
If the results don't meet your requirements: |
|
|
|
- **Not enough vehicles?** Increase the maximum vehicles allowed |
|
- **Time windows not met?** Decrease the time window importance or minimum compliance |
|
- **High priority deliveries not assigned?** Increase priority importance |
|
- **Routes too unbalanced?** Increase load balancing parameter |
|
|
|
Remember to click **Generate Optimal Routes** after changing any parameters to see the updated results. |
|
""") |
|
|
|
|
|
if 'optimization_result' not in st.session_state: |
|
st.session_state.optimization_result = None |
|
if 'optimization_params' not in st.session_state: |
|
st.session_state.optimization_params = { |
|
'priority_weight': 0.3, |
|
'time_window_weight': 0.5, |
|
'balance_weight': 0.2, |
|
'max_vehicles': 5, |
|
'selected_dates': ["All"] |
|
} |
|
if 'calendar_display_dates' not in st.session_state: |
|
st.session_state.calendar_display_dates = None |
|
|
|
if 'calculated_road_routes' not in st.session_state: |
|
st.session_state.calculated_road_routes = {} |
|
|
|
|
|
data = load_all_data() |
|
if not data: |
|
return |
|
|
|
delivery_data, vehicle_data, distance_matrix, time_matrix, locations = data |
|
|
|
|
|
st.sidebar.header("Optimization Parameters") |
|
|
|
|
|
if 'delivery_date' in delivery_data.columns: |
|
available_dates = sorted(delivery_data['delivery_date'].unique()) |
|
date_options = ["All"] + list(available_dates) |
|
|
|
|
|
current_selected_dates = st.session_state.optimization_params['selected_dates'] |
|
|
|
selected_dates = st.sidebar.multiselect( |
|
"Select Delivery Dates", |
|
options=date_options, |
|
default=current_selected_dates, |
|
key="delivery_date_selector" |
|
) |
|
|
|
|
|
if selected_dates != current_selected_dates: |
|
clear_optimization_results() |
|
st.session_state.optimization_params['selected_dates'] = selected_dates |
|
|
|
|
|
if "All" not in selected_dates: |
|
if selected_dates: |
|
delivery_data = delivery_data[delivery_data['delivery_date'].isin(selected_dates)] |
|
elif available_dates: |
|
st.sidebar.warning("No dates selected. Please select at least one delivery date.") |
|
return |
|
|
|
|
|
|
|
current_priority = st.session_state.optimization_params['priority_weight'] |
|
priority_weight = st.sidebar.slider( |
|
"Priority Importance", |
|
min_value=0.0, |
|
max_value=1.0, |
|
value=current_priority, |
|
help="Higher values give more importance to high-priority deliveries", |
|
key="priority_weight", |
|
on_change=clear_optimization_results |
|
) |
|
|
|
|
|
current_time_window = st.session_state.optimization_params['time_window_weight'] |
|
time_window_weight = st.sidebar.slider( |
|
"Time Window Importance", |
|
min_value=0.0, |
|
max_value=1.0, |
|
value=current_time_window, |
|
help="Higher values enforce stricter adherence to delivery time windows", |
|
key="time_window_weight", |
|
on_change=clear_optimization_results |
|
) |
|
|
|
|
|
current_balance = st.session_state.optimization_params['balance_weight'] |
|
balance_weight = st.sidebar.slider( |
|
"Load Balancing vs Distance", |
|
min_value=0.0, |
|
max_value=1.0, |
|
value=current_balance, |
|
help="Higher values prioritize even distribution of deliveries across vehicles over total distance", |
|
key="balance_weight", |
|
on_change=clear_optimization_results |
|
) |
|
|
|
|
|
available_vehicles = vehicle_data[vehicle_data['status'] == 'Available'] |
|
current_max_vehicles = st.session_state.optimization_params['max_vehicles'] |
|
max_vehicles = st.sidebar.slider( |
|
"Maximum Vehicles to Use", |
|
min_value=1, |
|
max_value=len(available_vehicles), |
|
value=min(current_max_vehicles, len(available_vehicles)), |
|
key="max_vehicles", |
|
on_change=clear_optimization_results |
|
) |
|
|
|
|
|
min_time_window_compliance = st.sidebar.slider( |
|
"Minimum Time Window Compliance (%)", |
|
min_value=0, |
|
max_value=100, |
|
value=75, |
|
help="Minimum percentage of deliveries that must be within their time window", |
|
key="min_time_window_compliance", |
|
on_change=clear_optimization_results |
|
) |
|
|
|
|
|
st.session_state.optimization_params['priority_weight'] = priority_weight |
|
st.session_state.optimization_params['time_window_weight'] = time_window_weight |
|
st.session_state.optimization_params['balance_weight'] = balance_weight |
|
st.session_state.optimization_params['max_vehicles'] = max_vehicles |
|
|
|
|
|
|
|
|
|
|
|
|
|
col1, col2 = st.columns([2, 1]) |
|
|
|
with col1: |
|
st.subheader("Delivery Route Optimizer") |
|
|
|
|
|
if 'status' in delivery_data.columns: |
|
pending_deliveries = delivery_data[delivery_data['status'] != 'Delivered'] |
|
completed_count = len(delivery_data) - len(pending_deliveries) |
|
else: |
|
pending_deliveries = delivery_data |
|
completed_count = 0 |
|
|
|
st.write(f"Optimizing routes for {len(pending_deliveries)} pending deliveries using up to {max_vehicles} vehicles") |
|
|
|
|
|
st.write("#### Delivery Statistics") |
|
total_count = len(delivery_data) |
|
pending_count = len(pending_deliveries) |
|
|
|
col1a, col1b = st.columns(2) |
|
with col1a: |
|
st.metric("Total Deliveries", total_count) |
|
with col1b: |
|
st.metric("Pending Deliveries", pending_count, |
|
delta=f"-{completed_count}" if completed_count > 0 else None, |
|
delta_color="inverse" if completed_count > 0 else "normal") |
|
|
|
if 'priority' in delivery_data.columns: |
|
|
|
priority_counts = pending_deliveries['priority'].value_counts() |
|
|
|
|
|
st.write("##### Priority Breakdown") |
|
priority_cols = st.columns(min(3, len(priority_counts))) |
|
|
|
for i, (priority, count) in enumerate(priority_counts.items()): |
|
col_idx = i % len(priority_cols) |
|
with priority_cols[col_idx]: |
|
st.metric(f"{priority}", count) |
|
|
|
if 'weight_kg' in delivery_data.columns: |
|
|
|
total_weight = pending_deliveries['weight_kg'].sum() |
|
st.metric("Total Weight (Pending)", f"{total_weight:.2f} kg") |
|
|
|
with col2: |
|
st.write("#### Vehicle Availability") |
|
st.write(f"Available Vehicles: {len(available_vehicles)}") |
|
|
|
|
|
if 'max_weight_kg' in vehicle_data.columns: |
|
total_capacity = available_vehicles['max_weight_kg'].sum() |
|
st.write(f"Total Capacity: {total_capacity:.2f} kg") |
|
|
|
|
|
if 'weight_kg' in delivery_data.columns: |
|
if total_capacity < total_weight: |
|
st.warning("β οΈ Insufficient vehicle capacity for all deliveries") |
|
else: |
|
st.success("β
Sufficient vehicle capacity") |
|
|
|
|
|
run_optimization_btn = st.button("Generate Optimal Routes") |
|
|
|
|
|
if run_optimization_btn or st.session_state.optimization_result is not None: |
|
if run_optimization_btn: |
|
|
|
with st.spinner("Calculating optimal routes..."): |
|
start_time = time.time() |
|
|
|
|
|
if 'status' in delivery_data.columns: |
|
pending_deliveries = delivery_data[delivery_data['status'] != 'Delivered'] |
|
else: |
|
pending_deliveries = delivery_data |
|
|
|
|
|
optimization_result = run_optimization( |
|
delivery_data=pending_deliveries, |
|
vehicle_data=available_vehicles.iloc[:max_vehicles], |
|
distance_matrix=distance_matrix, |
|
time_matrix=time_matrix, |
|
locations=locations, |
|
priority_weight=priority_weight, |
|
time_window_weight=time_window_weight, |
|
balance_weight=balance_weight, |
|
min_time_window_compliance=min_time_window_compliance/100.0 |
|
) |
|
|
|
end_time = time.time() |
|
st.success(f"Optimization completed in {end_time - start_time:.2f} seconds") |
|
|
|
|
|
st.session_state.optimization_result = optimization_result |
|
else: |
|
|
|
optimization_result = st.session_state.optimization_result |
|
|
|
|
|
if 'status' in delivery_data.columns: |
|
pending_deliveries = delivery_data[delivery_data['status'] != 'Delivered'] |
|
else: |
|
pending_deliveries = delivery_data |
|
|
|
|
|
display_optimization_results( |
|
optimization_result=optimization_result, |
|
delivery_data=pending_deliveries, |
|
vehicle_data=available_vehicles.iloc[:max_vehicles], |
|
distance_matrix=distance_matrix, |
|
time_matrix=time_matrix, |
|
locations=locations |
|
) |
|
|
|
def load_all_data(): |
|
""" |
|
Load all necessary data for optimization |
|
|
|
Returns: |
|
tuple of (delivery_data, vehicle_data, distance_matrix, time_matrix, locations) |
|
""" |
|
|
|
root_dir = Path(__file__).resolve().parent.parent.parent |
|
delivery_path = os.path.join(root_dir, 'data', 'delivery-data', 'delivery_data.csv') |
|
vehicle_path = os.path.join(root_dir, 'data', 'vehicle-data', 'vehicle_data.csv') |
|
distance_matrix_path = os.path.join(root_dir, 'data', 'time-matrix', 'distance_matrix.csv') |
|
time_matrix_path = os.path.join(root_dir, 'data', 'time-matrix', 'base_time_matrix.csv') |
|
locations_path = os.path.join(root_dir, 'data', 'time-matrix', 'locations.csv') |
|
|
|
|
|
missing_files = [] |
|
for path, name in [ |
|
(delivery_path, "delivery data"), |
|
(vehicle_path, "vehicle data"), |
|
(distance_matrix_path, "distance matrix"), |
|
(time_matrix_path, "time matrix"), |
|
(locations_path, "locations data") |
|
]: |
|
if not os.path.exists(path): |
|
missing_files.append(name) |
|
|
|
if missing_files: |
|
st.error(f"Missing required data: {', '.join(missing_files)}") |
|
st.info("Please generate all data first by running: python src/utils/generate_all_data.py") |
|
return None |
|
|
|
|
|
delivery_data = pd.read_csv(delivery_path) |
|
vehicle_data = pd.read_csv(vehicle_path) |
|
distance_matrix = pd.read_csv(distance_matrix_path, index_col=0) |
|
time_matrix = pd.read_csv(time_matrix_path, index_col=0) |
|
locations = pd.read_csv(locations_path) |
|
|
|
return delivery_data, vehicle_data, distance_matrix, time_matrix, locations |
|
|
|
def run_optimization(delivery_data, vehicle_data, distance_matrix, time_matrix, locations, |
|
priority_weight, time_window_weight, balance_weight, min_time_window_compliance=0.75): |
|
""" |
|
Run the route optimization algorithm using Google OR-Tools |
|
|
|
Parameters: |
|
delivery_data (pd.DataFrame): DataFrame containing delivery information |
|
vehicle_data (pd.DataFrame): DataFrame containing vehicle information |
|
distance_matrix (pd.DataFrame): Distance matrix between locations |
|
time_matrix (pd.DataFrame): Time matrix between locations |
|
locations (pd.DataFrame): DataFrame with location details |
|
priority_weight (float): Weight for delivery priority in optimization (Ξ±) |
|
time_window_weight (float): Weight for time window adherence (Ξ²) |
|
balance_weight (float): Weight for balancing load across vehicles (Ξ³) |
|
min_time_window_compliance (float): Minimum required time window compliance (Ξ΄) |
|
|
|
Returns: |
|
dict: Optimization results |
|
""" |
|
st.write("Setting up optimization model with OR-Tools...") |
|
|
|
|
|
num_vehicles = len(vehicle_data) |
|
num_deliveries = len(delivery_data) |
|
|
|
|
|
all_locations = [] |
|
delivery_locations = [] |
|
depot_locations = [] |
|
vehicle_capacities = [] |
|
|
|
|
|
for i, (_, vehicle) in enumerate(vehicle_data.iterrows()): |
|
depot_loc = { |
|
'id': vehicle['vehicle_id'], |
|
'type': 'depot', |
|
'index': i, |
|
'latitude': vehicle['depot_latitude'], |
|
'longitude': vehicle['depot_longitude'], |
|
'vehicle_index': i |
|
} |
|
depot_locations.append(depot_loc) |
|
all_locations.append(depot_loc) |
|
|
|
|
|
if 'max_weight_kg' in vehicle: |
|
vehicle_capacities.append(int(vehicle['max_weight_kg'] * 100)) |
|
else: |
|
vehicle_capacities.append(1000) |
|
|
|
|
|
for i, (_, delivery) in enumerate(delivery_data.iterrows()): |
|
|
|
priority_factor = 1.0 |
|
if 'priority' in delivery: |
|
if delivery['priority'] == 'High': |
|
priority_factor = 0.5 |
|
elif delivery['priority'] == 'Low': |
|
priority_factor = 2.0 |
|
|
|
|
|
demand = int(delivery.get('weight_kg', 1.0) * 100) |
|
|
|
delivery_loc = { |
|
'id': delivery['delivery_id'], |
|
'type': 'delivery', |
|
'index': num_vehicles + i, |
|
'latitude': delivery['latitude'], |
|
'longitude': delivery['longitude'], |
|
'priority': delivery.get('priority', 'Medium'), |
|
'priority_factor': priority_factor, |
|
'weight_kg': delivery.get('weight_kg', 1.0), |
|
'demand': demand, |
|
'time_window': delivery.get('time_window', '09:00-17:00'), |
|
'customer_name': delivery.get('customer_name', 'Unknown') |
|
} |
|
delivery_locations.append(delivery_loc) |
|
all_locations.append(delivery_loc) |
|
|
|
|
|
dist_matrix = np.zeros((len(all_locations), len(all_locations))) |
|
time_matrix_mins = np.zeros((len(all_locations), len(all_locations))) |
|
|
|
|
|
if isinstance(distance_matrix, pd.DataFrame) and len(distance_matrix) == len(all_locations): |
|
|
|
dist_matrix = distance_matrix.values |
|
time_matrix_mins = time_matrix.values |
|
else: |
|
|
|
for i in range(len(all_locations)): |
|
for j in range(len(all_locations)): |
|
if i == j: |
|
continue |
|
|
|
|
|
lat1, lon1 = all_locations[i]['latitude'], all_locations[i]['longitude'] |
|
lat2, lon2 = all_locations[j]['latitude'], all_locations[j]['longitude'] |
|
|
|
|
|
dist = ((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2) ** 0.5 * 111 |
|
dist_matrix[i, j] = dist |
|
time_matrix_mins[i, j] = dist * 2 |
|
|
|
|
|
demands = [0] * num_vehicles + [d['demand'] for d in delivery_locations] |
|
|
|
|
|
total_delivery_weight = sum(d['demand'] for d in delivery_locations) |
|
|
|
|
|
|
|
manager = pywrapcp.RoutingIndexManager( |
|
len(all_locations), |
|
num_vehicles, |
|
list(range(num_vehicles)), |
|
list(range(num_vehicles)) |
|
) |
|
|
|
|
|
routing = pywrapcp.RoutingModel(manager) |
|
|
|
|
|
|
|
def distance_callback(from_index, to_index): |
|
"""Returns the weighted distance between the two nodes.""" |
|
|
|
from_node = manager.IndexToNode(from_index) |
|
to_node = manager.IndexToNode(to_index) |
|
|
|
|
|
base_distance = int(dist_matrix[from_node, to_node] * 1000) |
|
|
|
|
|
if to_node >= num_vehicles: |
|
delivery_idx = to_node - num_vehicles |
|
|
|
priority_factor = delivery_locations[delivery_idx]['priority_factor'] |
|
|
|
priority_multiplier = priority_factor ** priority_weight |
|
return int(base_distance * priority_multiplier) |
|
|
|
return base_distance |
|
|
|
|
|
def time_callback(from_index, to_index): |
|
"""Returns the travel time between the two nodes.""" |
|
|
|
from_node = manager.IndexToNode(from_index) |
|
to_node = manager.IndexToNode(to_index) |
|
return int(time_matrix_mins[from_node, to_node] * 60) |
|
|
|
|
|
def service_time_callback(from_index): |
|
"""Returns the service time for the node.""" |
|
|
|
node_idx = manager.IndexToNode(from_index) |
|
if node_idx >= num_vehicles: |
|
return 600 |
|
return 0 |
|
|
|
|
|
def demand_callback(from_index): |
|
"""Returns the demand of the node.""" |
|
|
|
from_node = manager.IndexToNode(from_index) |
|
return demands[from_node] |
|
|
|
|
|
transit_callback_index = routing.RegisterTransitCallback(distance_callback) |
|
time_callback_index = routing.RegisterTransitCallback(time_callback) |
|
service_callback_index = routing.RegisterUnaryTransitCallback(service_time_callback) |
|
demand_callback_index = routing.RegisterUnaryTransitCallback(demand_callback) |
|
|
|
|
|
routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index) |
|
|
|
|
|
routing.AddDimensionWithVehicleCapacity( |
|
demand_callback_index, |
|
0, |
|
vehicle_capacities, |
|
True, |
|
'Capacity' |
|
) |
|
|
|
capacity_dimension = routing.GetDimensionOrDie('Capacity') |
|
|
|
|
|
if balance_weight > 0.01: |
|
|
|
target_weight = total_delivery_weight / len(vehicle_capacities) |
|
|
|
for i in range(num_vehicles): |
|
|
|
vehicle_capacity = vehicle_capacities[i] |
|
|
|
|
|
|
|
balance_penalty = int(10000 * balance_weight) |
|
|
|
|
|
|
|
lower_target = max(0, int(target_weight * 0.8)) |
|
capacity_dimension.SetCumulVarSoftLowerBound( |
|
routing.End(i), lower_target, balance_penalty |
|
) |
|
|
|
|
|
|
|
upper_target = min(vehicle_capacity, int(target_weight * 1.2)) |
|
capacity_dimension.SetCumulVarSoftUpperBound( |
|
routing.End(i), upper_target, balance_penalty |
|
) |
|
|
|
|
|
|
|
|
|
routing.AddDimension( |
|
time_callback_index, |
|
60 * 60, |
|
24 * 60 * 60, |
|
False, |
|
'Time' |
|
) |
|
time_dimension = routing.GetDimensionOrDie('Time') |
|
|
|
|
|
for node_idx in range(len(all_locations)): |
|
index = manager.NodeToIndex(node_idx) |
|
time_dimension.SetCumulVarSoftUpperBound( |
|
index, |
|
24 * 60 * 60, |
|
1000000 |
|
) |
|
time_dimension.SlackVar(index).SetValue(0) |
|
|
|
|
|
time_window_vars = [] |
|
compliance_threshold = int(min_time_window_compliance * num_deliveries) |
|
|
|
|
|
if time_window_weight > 0.01: |
|
|
|
for delivery_idx, delivery in enumerate(delivery_locations): |
|
if 'time_window' in delivery and delivery['time_window']: |
|
try: |
|
start_time_str, end_time_str = delivery['time_window'].split('-') |
|
start_hour, start_min = map(int, start_time_str.split(':')) |
|
end_hour, end_min = map(int, end_time_str.split(':')) |
|
|
|
|
|
start_time_sec = (start_hour * 60 + start_min) * 60 |
|
end_time_sec = (end_hour * 60 + end_min) * 60 |
|
|
|
|
|
index = manager.NodeToIndex(num_vehicles + delivery_idx) |
|
|
|
|
|
|
|
time_dimension.SetCumulVarSoftUpperBound( |
|
index, |
|
end_time_sec, |
|
int(1000000 * time_window_weight) |
|
) |
|
|
|
|
|
time_dimension.CumulVar(index).SetMin(start_time_sec) |
|
|
|
|
|
time_window_vars.append((index, start_time_sec, end_time_sec)) |
|
except: |
|
|
|
pass |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
search_parameters = pywrapcp.DefaultRoutingSearchParameters() |
|
|
|
|
|
search_parameters.local_search_metaheuristic = ( |
|
routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH |
|
) |
|
|
|
|
|
search_parameters.first_solution_strategy = ( |
|
routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC |
|
) |
|
|
|
|
|
search_parameters.time_limit.seconds = 10 |
|
|
|
|
|
search_parameters.log_search = True |
|
|
|
|
|
if compliance_threshold > 0: |
|
|
|
routing.CloseModelWithParameters(search_parameters) |
|
|
|
|
|
st.write(f"Solving optimization model with {num_deliveries} deliveries and {num_vehicles} vehicles...") |
|
st.write(f"Target: At least {compliance_threshold} of {num_deliveries} deliveries ({min_time_window_compliance*100:.0f}%) must be within time windows") |
|
|
|
solution = routing.SolveWithParameters(search_parameters) |
|
else: |
|
|
|
solution = routing.SolveWithParameters(search_parameters) |
|
|
|
|
|
if not solution: |
|
st.warning("Could not find a solution with all deliveries assigned. Trying a relaxed version...") |
|
|
|
|
|
routing = pywrapcp.RoutingModel(manager) |
|
|
|
|
|
transit_callback_index = routing.RegisterTransitCallback(distance_callback) |
|
time_callback_index = routing.RegisterTransitCallback(time_callback) |
|
service_callback_index = routing.RegisterUnaryTransitCallback(service_time_callback) |
|
demand_callback_index = routing.RegisterUnaryTransitCallback(demand_callback) |
|
|
|
routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index) |
|
|
|
|
|
routing.AddDimensionWithVehicleCapacity( |
|
demand_callback_index, |
|
0, vehicle_capacities, True, 'Capacity' |
|
) |
|
|
|
|
|
routing.AddDimension( |
|
time_callback_index, |
|
60 * 60, 24 * 60 * 60, False, 'Time' |
|
) |
|
time_dimension = routing.GetDimensionOrDie('Time') |
|
|
|
|
|
for delivery_idx in range(num_deliveries): |
|
index = manager.NodeToIndex(num_vehicles + delivery_idx) |
|
routing.AddDisjunction([index], 1000000) |
|
|
|
|
|
search_parameters.time_limit.seconds = 15 |
|
solution = routing.SolveWithParameters(search_parameters) |
|
|
|
if not solution: |
|
st.error("Could not find any solution. Try increasing the number of vehicles or relaxing other constraints.") |
|
return { |
|
'routes': {}, |
|
'stats': {}, |
|
'parameters': { |
|
'priority_weight': priority_weight, |
|
'time_window_weight': time_window_weight, |
|
'balance_weight': balance_weight, |
|
'min_time_window_compliance': min_time_window_compliance |
|
} |
|
} |
|
|
|
|
|
optimized_routes = {} |
|
route_stats = {} |
|
|
|
if solution: |
|
st.success("Solution found!") |
|
|
|
total_time_window_compliance = 0 |
|
total_deliveries_assigned = 0 |
|
|
|
for vehicle_idx in range(num_vehicles): |
|
route = [] |
|
vehicle_id = vehicle_data.iloc[vehicle_idx]['vehicle_id'] |
|
|
|
|
|
vehicle_info = { |
|
'id': vehicle_id, |
|
'type': vehicle_data.iloc[vehicle_idx].get('vehicle_type', 'Standard'), |
|
'capacity': vehicle_data.iloc[vehicle_idx].get('max_weight_kg', 1000), |
|
'depot_latitude': vehicle_data.iloc[vehicle_idx]['depot_latitude'], |
|
'depot_longitude': vehicle_data.iloc[vehicle_idx]['depot_longitude'] |
|
} |
|
|
|
|
|
index = routing.Start(vehicle_idx) |
|
total_distance = 0 |
|
total_time = 0 |
|
total_load = 0 |
|
time_window_compliant = 0 |
|
total_deliveries = 0 |
|
|
|
|
|
current_time_sec = 8 * 3600 |
|
|
|
while not routing.IsEnd(index): |
|
|
|
node_idx = manager.IndexToNode(index) |
|
|
|
|
|
if node_idx >= num_vehicles: |
|
|
|
delivery_idx = node_idx - num_vehicles |
|
delivery = delivery_locations[delivery_idx].copy() |
|
|
|
|
|
arrival_time_sec = solution.Min(time_dimension.CumulVar(index)) |
|
arrival_time_mins = arrival_time_sec // 60 |
|
|
|
|
|
delivery['estimated_arrival'] = arrival_time_mins |
|
|
|
|
|
if 'time_window' in delivery and delivery['time_window']: |
|
try: |
|
start_time_str, end_time_str = delivery['time_window'].split('-') |
|
start_hour, start_min = map(int, start_time_str.split(':')) |
|
end_hour, end_min = map(int, end_time_str.split(':')) |
|
|
|
|
|
start_mins = start_hour * 60 + start_min |
|
end_mins = end_hour * 60 + end_min |
|
|
|
|
|
on_time = False |
|
|
|
|
|
if arrival_time_mins <= end_mins: |
|
on_time = True |
|
time_window_compliant += 1 |
|
total_time_window_compliance += 1 |
|
|
|
delivery['within_time_window'] = on_time |
|
except Exception as e: |
|
st.warning(f"Error parsing time window for delivery {delivery['id']}: {str(e)}") |
|
delivery['within_time_window'] = False |
|
|
|
|
|
route.append(delivery) |
|
total_deliveries += 1 |
|
total_deliveries_assigned += 1 |
|
|
|
|
|
total_load += delivery['demand'] / 100 |
|
|
|
|
|
previous_idx = index |
|
index = solution.Value(routing.NextVar(index)) |
|
|
|
|
|
if not routing.IsEnd(index): |
|
previous_node = manager.IndexToNode(previous_idx) |
|
next_node = manager.IndexToNode(index) |
|
|
|
|
|
segment_distance = dist_matrix[previous_node, next_node] |
|
total_distance += segment_distance |
|
|
|
|
|
segment_time_sec = int(time_matrix_mins[previous_node, next_node] * 60) |
|
total_time += segment_time_sec / 60 |
|
|
|
|
|
if route: |
|
optimized_routes[vehicle_id] = route |
|
|
|
|
|
time_window_percent = (time_window_compliant / total_deliveries * 100) if total_deliveries > 0 else 0 |
|
|
|
|
|
route_stats[vehicle_id] = { |
|
'vehicle_type': vehicle_info['type'], |
|
'capacity_kg': vehicle_info['capacity'], |
|
'deliveries': len(route), |
|
'total_distance_km': round(total_distance, 2), |
|
'estimated_time_mins': round(total_time), |
|
'total_load_kg': round(total_load, 2), |
|
'time_window_compliant': time_window_compliant, |
|
'time_window_compliance': time_window_percent |
|
} |
|
|
|
|
|
overall_compliance = 0 |
|
if total_deliveries_assigned > 0: |
|
overall_compliance = (total_time_window_compliance / total_deliveries_assigned) |
|
|
|
if overall_compliance < min_time_window_compliance: |
|
st.warning(f"Solution found, but time window compliance ({overall_compliance*100:.1f}%) is below the minimum required ({min_time_window_compliance*100:.0f}%).") |
|
st.info("Consider adjusting parameters: increase the number of vehicles, reduce the minimum compliance requirement, or adjust time window importance.") |
|
else: |
|
st.success(f"Solution meets time window compliance requirement: {overall_compliance*100:.1f}% (minimum required: {min_time_window_compliance*100:.0f}%)") |
|
else: |
|
st.error("No solution found. Try adjusting the parameters.") |
|
optimized_routes = {} |
|
route_stats = {} |
|
|
|
return { |
|
'routes': optimized_routes, |
|
'stats': route_stats, |
|
'parameters': { |
|
'priority_weight': priority_weight, |
|
'time_window_weight': time_window_weight, |
|
'balance_weight': balance_weight, |
|
'min_time_window_compliance': min_time_window_compliance |
|
} |
|
} |
|
|
|
def display_optimization_results(optimization_result, delivery_data, vehicle_data, |
|
distance_matrix, time_matrix, locations): |
|
""" |
|
Display the optimization results |
|
|
|
Parameters: |
|
optimization_result (dict): Result from the optimization algorithm |
|
delivery_data (pd.DataFrame): Delivery information |
|
vehicle_data (pd.DataFrame): Vehicle information |
|
distance_matrix (pd.DataFrame): Distance matrix between locations |
|
time_matrix (pd.DataFrame): Time matrix between locations |
|
locations (pd.DataFrame): Location details |
|
""" |
|
|
|
colors = ['blue', 'red', 'green', 'purple', 'orange', 'darkblue', |
|
'darkred', 'darkgreen', 'cadetblue', 'darkpurple', 'pink', |
|
'lightblue', 'lightred', 'lightgreen', 'gray', 'black', 'lightgray'] |
|
|
|
routes = optimization_result['routes'] |
|
|
|
|
|
st.subheader("Optimization Results") |
|
|
|
|
|
total_deliveries = sum(len(route) for route in routes.values()) |
|
active_vehicles = sum(1 for route in routes.values() if len(route) > 0) |
|
|
|
|
|
total_distance = sum(stats.get('total_distance_km', 0) for stats in optimization_result.get('stats', {}).values()) |
|
total_time_mins = sum(stats.get('estimated_time_mins', 0) for stats in optimization_result.get('stats', {}).values()) |
|
|
|
|
|
on_time_deliveries = 0 |
|
total_route_deliveries = 0 |
|
|
|
|
|
for vehicle_id, route in routes.items(): |
|
stats = optimization_result.get('stats', {}).get(vehicle_id, {}) |
|
|
|
|
|
if stats and 'time_window_compliant' in stats: |
|
|
|
on_time_deliveries += stats['time_window_compliant'] |
|
else: |
|
|
|
for delivery in route: |
|
if 'time_window' in delivery and 'estimated_arrival' in delivery: |
|
|
|
try: |
|
time_window = delivery['time_window'] |
|
start_time_str, end_time_str = time_window.split('-') |
|
|
|
|
|
start_mins = int(start_time_str.split(':')[0]) * 60 + int(start_time_str.split(':')[1]) |
|
end_mins = int(end_time_str.split(':')[0]) * 60 + int(end_time_str.split(':')[1]) |
|
arrival_mins = delivery.get('estimated_arrival', 0) |
|
|
|
|
|
if arrival_mins <= end_mins: |
|
on_time_deliveries += 1 |
|
except: |
|
pass |
|
|
|
total_route_deliveries += len(route) |
|
|
|
|
|
delivery_ontime_percent = 0 |
|
if total_route_deliveries > 0: |
|
delivery_ontime_percent = (on_time_deliveries / total_route_deliveries) * 100 |
|
|
|
|
|
st.write("### Overall Performance") |
|
col1, col2, col3 = st.columns(3) |
|
with col1: |
|
st.metric("Deliveries Assigned", f"{total_deliveries}/{len(delivery_data)}") |
|
st.metric("Vehicles Used", f"{active_vehicles}/{len(vehicle_data)}") |
|
|
|
with col2: |
|
st.metric("Total Distance", f"{total_distance:.1f} km") |
|
st.metric("Total Time", f"{int(total_time_mins//60)}h {int(total_time_mins%60)}m") |
|
|
|
with col3: |
|
st.metric("Time Window Compliance", f"{delivery_ontime_percent:.0f}%") |
|
|
|
|
|
if total_deliveries > 0: |
|
efficiency = (total_distance * 1000) / total_deliveries |
|
st.metric("Avg Distance per Delivery", f"{efficiency:.0f} m") |
|
|
|
|
|
st.write("### Time & Distance Distribution by Vehicle") |
|
time_data = {vehicle_id: stats.get('estimated_time_mins', 0) |
|
for vehicle_id, stats in optimization_result.get('stats', {}).items() |
|
if len(routes.get(vehicle_id, [])) > 0} |
|
|
|
if time_data: |
|
|
|
time_df = pd.DataFrame({ |
|
'Vehicle': list(time_data.keys()), |
|
'Time (mins)': list(time_data.values()) |
|
}) |
|
|
|
distance_data = {vehicle_id: stats.get('total_distance_km', 0) |
|
for vehicle_id, stats in optimization_result.get('stats', {}).items() |
|
if len(routes.get(vehicle_id, [])) > 0} |
|
|
|
distance_df = pd.DataFrame({ |
|
'Vehicle': list(distance_data.keys()), |
|
'Distance (km)': list(distance_data.values()) |
|
}) |
|
|
|
col1, col2 = st.columns(2) |
|
with col1: |
|
st.bar_chart(time_df.set_index('Vehicle')) |
|
with col2: |
|
st.bar_chart(distance_df.set_index('Vehicle')) |
|
|
|
|
|
st.subheader("Route Map with Road Navigation") |
|
|
|
|
|
st.info(""" |
|
The map shows delivery routes that follow road networks from the depot to each stop in sequence, and back to the depot. |
|
Numbered circles indicate the stop sequence, and arrows show travel direction. |
|
""") |
|
|
|
|
|
if 'delivery_date' in delivery_data.columns: |
|
|
|
available_dates = sorted(pd.to_datetime(delivery_data['delivery_date'].unique())) |
|
|
|
|
|
date_options = {} |
|
for date in available_dates: |
|
|
|
if isinstance(date, str): |
|
date_obj = pd.to_datetime(date) |
|
else: |
|
date_obj = date |
|
|
|
date_str = date_obj.strftime('%b %d, %Y') |
|
date_options[date_str] = date_obj |
|
|
|
|
|
default_date = min(available_dates) if available_dates else None |
|
default_date_str = default_date.strftime('%b %d, %Y') if default_date else None |
|
|
|
|
|
selected_date_str = st.selectbox( |
|
"Select date to show routes for:", |
|
options=list(date_options.keys()), |
|
index=0 if default_date_str else None, |
|
) |
|
|
|
|
|
selected_date = date_options[selected_date_str] if selected_date_str else None |
|
|
|
|
|
if selected_date is not None: |
|
filtered_routes = {} |
|
|
|
for vehicle_id, route in routes.items(): |
|
|
|
filtered_route = [] |
|
|
|
for delivery in route: |
|
delivery_id = delivery['id'] |
|
|
|
delivery_row = delivery_data[delivery_data['delivery_id'] == delivery_id] |
|
|
|
if not delivery_row.empty and 'delivery_date' in delivery_row: |
|
delivery_date = delivery_row['delivery_date'].iloc[0] |
|
|
|
|
|
if pd.to_datetime(delivery_date).date() == pd.to_datetime(selected_date).date(): |
|
filtered_route.append(delivery) |
|
|
|
|
|
if filtered_route: |
|
filtered_routes[vehicle_id] = filtered_route |
|
|
|
|
|
routes_for_map = filtered_routes |
|
st.write(f"Showing routes for {len(routes_for_map)} vehicles on {selected_date_str}") |
|
else: |
|
routes_for_map = routes |
|
else: |
|
routes_for_map = routes |
|
st.warning("No delivery dates available in data. Showing all routes.") |
|
|
|
|
|
singapore_coords = [1.3521, 103.8198] |
|
m = folium.Map(location=singapore_coords, zoom_start=12) |
|
|
|
|
|
|
|
total_segments = sum(len(route) + 1 for route in routes_for_map.values() if route) |
|
|
|
|
|
optimization_key = hash(str(optimization_result)) |
|
|
|
|
|
if optimization_key not in st.session_state.calculated_road_routes: |
|
|
|
st.session_state.calculated_road_routes[optimization_key] = {} |
|
|
|
|
|
total_segments = sum(len(route) + 1 for route in routes_for_map.values() if route) |
|
route_progress = st.progress(0) |
|
progress_container = st.empty() |
|
progress_container.text("Calculating routes: 0%") |
|
|
|
|
|
processed_segments = 0 |
|
|
|
for i, (vehicle_id, route) in enumerate(routes_for_map.items()): |
|
if not route: |
|
continue |
|
|
|
|
|
vehicle_info = vehicle_data[vehicle_data['vehicle_id'] == vehicle_id].iloc[0] |
|
|
|
|
|
color = colors[i % len(colors)] |
|
|
|
|
|
depot_lat, depot_lon = vehicle_info['depot_latitude'], vehicle_info['depot_longitude'] |
|
|
|
|
|
depot_popup = f""" |
|
<b>Depot:</b> {vehicle_id}<br> |
|
<b>Vehicle Type:</b> {vehicle_info['vehicle_type']}<br> |
|
<b>Driver:</b> {vehicle_info.get('driver_name', 'Unknown')}<br> |
|
""" |
|
|
|
|
|
folium.Marker( |
|
[depot_lat, depot_lon], |
|
popup=folium.Popup(depot_popup, max_width=300), |
|
tooltip=f"Depot: {vehicle_id} (START/END)", |
|
icon=folium.Icon(color=color, icon='home', prefix='fa') |
|
).add_to(m) |
|
|
|
|
|
waypoints = [(depot_lat, depot_lon)] |
|
|
|
|
|
for delivery in route: |
|
waypoints.append((delivery['latitude'], delivery['longitude'])) |
|
|
|
|
|
waypoints.append((depot_lat, depot_lon)) |
|
|
|
|
|
for j, delivery in enumerate(route): |
|
lat, lon = delivery['latitude'], delivery['longitude'] |
|
|
|
|
|
popup_content = f""" |
|
<b>Stop {j+1}:</b> {delivery['id']}<br> |
|
<b>Customer:</b> {delivery.get('customer_name', 'Unknown')}<br> |
|
""" |
|
|
|
if 'priority' in delivery: |
|
popup_content += f"<b>Priority:</b> {delivery['priority']}<br>" |
|
|
|
if 'weight_kg' in delivery: |
|
popup_content += f"<b>Weight:</b> {delivery['weight_kg']:.2f} kg<br>" |
|
|
|
if 'time_window' in delivery: |
|
popup_content += f"<b>Time Window:</b> {delivery['time_window']}<br>" |
|
|
|
|
|
folium.Circle( |
|
location=[lat, lon], |
|
radius=50, |
|
color=color, |
|
fill=True, |
|
fill_color=color, |
|
fill_opacity=0.7, |
|
tooltip=f"Stop {j+1}: {delivery['id']}" |
|
).add_to(m) |
|
|
|
|
|
folium.map.Marker( |
|
[lat, lon], |
|
icon=DivIcon( |
|
icon_size=(20, 20), |
|
icon_anchor=(10, 10), |
|
html=f'<div style="font-size: 12pt; color: #444444; font-weight: bold; text-align: center;">{j+1}</div>', |
|
) |
|
).add_to(m) |
|
|
|
|
|
folium.Marker( |
|
[lat + 0.0003, lon], |
|
popup=folium.Popup(popup_content, max_width=300), |
|
tooltip=f"Delivery {delivery['id']}", |
|
icon=folium.Icon(color=color, icon='box', prefix='fa') |
|
).add_to(m) |
|
|
|
|
|
for k in range(len(waypoints) - 1): |
|
|
|
start_point = waypoints[k] |
|
end_point = waypoints[k+1] |
|
|
|
|
|
route_key = f"{vehicle_id}_{k}" |
|
|
|
|
|
segment_desc = "depot" if k == 0 else f"stop {k}" |
|
next_desc = f"stop {k+1}" if k < len(waypoints) - 2 else "depot" |
|
|
|
|
|
if route_key in st.session_state.calculated_road_routes[optimization_key]: |
|
|
|
road_route = st.session_state.calculated_road_routes[optimization_key][route_key] |
|
progress_text = f"Using stored route for Vehicle {vehicle_id}: {segment_desc} β {next_desc}" |
|
else: |
|
|
|
progress_text = f"Calculating route for Vehicle {vehicle_id}: {segment_desc} β {next_desc}" |
|
with st.spinner(progress_text): |
|
|
|
road_route = get_road_route(start_point, end_point) |
|
|
|
st.session_state.calculated_road_routes[optimization_key][route_key] = road_route |
|
|
|
|
|
folium.PolyLine( |
|
road_route, |
|
color=color, |
|
weight=4, |
|
opacity=0.8, |
|
tooltip=f"Route {vehicle_id}: {segment_desc} β {next_desc}" |
|
).add_to(m) |
|
|
|
|
|
idx = int(len(road_route) * 0.7) |
|
if idx < len(road_route) - 1: |
|
p1 = road_route[idx] |
|
p2 = road_route[idx + 1] |
|
|
|
|
|
dy = p2[0] - p1[0] |
|
dx = p2[1] - p1[1] |
|
angle = (90 - np.degrees(np.arctan2(dy, dx))) % 360 |
|
|
|
|
|
folium.RegularPolygonMarker( |
|
location=p1, |
|
number_of_sides=3, |
|
radius=8, |
|
rotation=angle, |
|
color=color, |
|
fill_color=color, |
|
fill_opacity=0.8 |
|
).add_to(m) |
|
|
|
|
|
processed_segments += 1 |
|
progress_percentage = int((processed_segments / total_segments) * 100) |
|
route_progress.progress(processed_segments / total_segments) |
|
progress_container.text(f"Calculating routes: {progress_percentage}%") |
|
|
|
|
|
if optimization_key in st.session_state.calculated_road_routes: |
|
cached_count = len(st.session_state.calculated_road_routes[optimization_key]) |
|
if cached_count > 0 and cached_count >= processed_segments: |
|
st.info(f"β
Using {cached_count} previously calculated routes. No recalculation needed.") |
|
|
|
|
|
progress_container.empty() |
|
route_progress.empty() |
|
st.success("All routes calculated successfully!") |
|
|
|
|
|
folium_static(m, width=800, height=600) |
|
|
|
|
|
|
|
|
|
st.subheader("Schedule Calendar View") |
|
st.write("This calendar shows both delivery schedules and vehicle assignments. On-time deliveries are shown in green, late deliveries in red.") |
|
|
|
|
|
if routes: |
|
|
|
calendar_data = [] |
|
|
|
|
|
assigned_delivery_ids = set() |
|
|
|
|
|
for vehicle_id, route in routes.items(): |
|
for delivery in route: |
|
assigned_delivery_ids.add(delivery['id']) |
|
|
|
vehicle_info = vehicle_data[vehicle_data['vehicle_id'] == vehicle_id].iloc[0] |
|
vehicle_type = vehicle_info.get('vehicle_type', 'Standard') |
|
driver_name = vehicle_info.get('driver_name', 'Unknown') |
|
|
|
|
|
delivery_id = delivery['id'] |
|
customer_name = delivery.get('customer_name', 'Unknown') |
|
priority = delivery.get('priority', 'Medium') |
|
time_window = delivery.get('time_window', '09:00-17:00') |
|
weight = delivery.get('weight_kg', 0) |
|
|
|
|
|
start_time_str, end_time_str = time_window.split('-') |
|
|
|
|
|
delivery_row = delivery_data[delivery_data['delivery_id'] == delivery_id] |
|
delivery_date = delivery_row['delivery_date'].iloc[0] if not delivery_row.empty and 'delivery_date' in delivery_row else datetime.now().date() |
|
|
|
|
|
try: |
|
|
|
if isinstance(delivery_date, pd.Timestamp): |
|
date_str = delivery_date.strftime('%Y-%m-%d') |
|
elif isinstance(delivery_date, str): |
|
date_str = pd.to_datetime(delivery_date).strftime('%Y-%m-%d') |
|
else: |
|
date_str = delivery_date.strftime('%Y-%m-%d') |
|
|
|
start_datetime = pd.to_datetime(f"{date_str} {start_time_str}") |
|
end_datetime = pd.to_datetime(f"{date_str} {end_time_str}") |
|
|
|
|
|
estimated_arrival_mins = delivery.get('estimated_arrival', 0) |
|
|
|
|
|
start_mins = int(start_time_str.split(':')[0]) * 60 + int(start_time_str.split(':')[1]) |
|
end_mins = int(end_time_str.split(':')[0]) * 60 + int(end_time_str.split(':')[1]) |
|
|
|
|
|
on_time = start_mins <= estimated_arrival_mins <= end_mins |
|
|
|
|
|
if on_time: |
|
|
|
color = 'on_time' |
|
else: |
|
|
|
color = 'late' |
|
|
|
calendar_data.append({ |
|
'delivery_id': delivery_id, |
|
'customer_name': customer_name, |
|
'vehicle_id': vehicle_id, |
|
'driver_name': driver_name, |
|
'vehicle_type': vehicle_type, |
|
'priority': priority, |
|
'time_window': time_window, |
|
'estimated_arrival_mins': estimated_arrival_mins, |
|
'estimated_arrival_time': f"{estimated_arrival_mins//60:02d}:{estimated_arrival_mins%60:02d}", |
|
'weight_kg': weight, |
|
'Start': start_datetime, |
|
'Finish': end_datetime, |
|
'Task': f"{delivery_id}: {customer_name}", |
|
'Vehicle Task': f"{vehicle_id}: {driver_name}", |
|
'on_time': on_time, |
|
'assigned': True, |
|
'color': color, |
|
'delivery_date': pd.to_datetime(date_str) |
|
}) |
|
except Exception as e: |
|
st.warning(f"Could not process time window for delivery {delivery_id}: {str(e)}") |
|
|
|
|
|
for _, row in delivery_data.iterrows(): |
|
delivery_id = row['delivery_id'] |
|
|
|
|
|
if delivery_id in assigned_delivery_ids: |
|
continue |
|
|
|
|
|
customer_name = row.get('customer_name', 'Unknown') |
|
priority = row.get('priority', 'Medium') |
|
time_window = row.get('time_window', '09:00-17:00') |
|
weight = row.get('weight_kg', 0) |
|
|
|
|
|
start_time_str, end_time_str = time_window.split('-') |
|
|
|
|
|
if 'delivery_date' in row: |
|
delivery_date = row['delivery_date'] |
|
else: |
|
delivery_date = datetime.now().date() |
|
|
|
|
|
try: |
|
|
|
if isinstance(delivery_date, pd.Timestamp): |
|
date_str = delivery_date.strftime('%Y-%m-%d') |
|
elif isinstance(delivery_date, str): |
|
date_str = pd.to_datetime(delivery_date).strftime('%Y-%m-%d') |
|
else: |
|
date_str = delivery_date.strftime('%Y-%m-%d') |
|
|
|
start_datetime = pd.to_datetime(f"{date_str} {start_time_str}") |
|
end_datetime = pd.to_datetime(f"{date_str} {end_time_str}") |
|
|
|
|
|
calendar_data.append({ |
|
'delivery_id': delivery_id, |
|
'customer_name': customer_name, |
|
'vehicle_id': 'Unassigned', |
|
'driver_name': 'N/A', |
|
'vehicle_type': 'N/A', |
|
'priority': priority, |
|
'time_window': time_window, |
|
'estimated_arrival_mins': 0, |
|
'estimated_arrival_time': 'N/A', |
|
'weight_kg': weight, |
|
'Start': start_datetime, |
|
'Finish': end_datetime, |
|
'Task': f"{delivery_id}: {customer_name} (UNASSIGNED)", |
|
'Vehicle Task': 'Unassigned', |
|
'on_time': False, |
|
'assigned': False, |
|
'color': 'unassigned', |
|
'delivery_date': pd.to_datetime(date_str) |
|
}) |
|
except Exception as e: |
|
st.warning(f"Could not process time window for unassigned delivery {delivery_id}: {str(e)}") |
|
|
|
if calendar_data: |
|
|
|
cal_df = pd.DataFrame(calendar_data) |
|
|
|
|
|
cal_df['Color'] = cal_df['on_time'].map({True: 'rgb(0, 200, 0)', False: 'rgb(255, 0, 0)'}) |
|
|
|
|
|
all_dates = sorted(cal_df['delivery_date'].dt.date.unique()) |
|
|
|
|
|
date_options = {date.strftime('%b %d, %Y'): date for date in all_dates} |
|
|
|
|
|
available_date_keys = list(date_options.keys()) |
|
|
|
|
|
if st.session_state.calendar_display_dates is None or not all(date in available_date_keys for date in st.session_state.calendar_display_dates): |
|
st.session_state.calendar_display_dates = available_date_keys |
|
|
|
|
|
selected_date_strings = st.multiselect( |
|
"Select dates to display", |
|
options=available_date_keys, |
|
default=st.session_state.calendar_display_dates, |
|
key="calendar_date_selector" |
|
) |
|
|
|
|
|
st.session_state.calendar_display_dates = selected_date_strings |
|
|
|
|
|
selected_dates = [date_options[date_str] for date_str in selected_date_strings] |
|
|
|
if not selected_dates: |
|
st.info("Please select at least one date to view the delivery schedule.") |
|
else: |
|
|
|
filtered_cal_df = cal_df[cal_df['delivery_date'].dt.date.isin(selected_dates)] |
|
|
|
|
|
date_tabs = st.tabs([date.strftime('%b %d, %Y') for date in selected_dates]) |
|
|
|
for i, (date, tab) in enumerate(zip(selected_dates, date_tabs)): |
|
with tab: |
|
|
|
day_data = filtered_cal_df[filtered_cal_df['delivery_date'].dt.date == date] |
|
|
|
if len(day_data) > 0: |
|
|
|
st.write("#### Delivery Schedule") |
|
|
|
|
|
fig = px.timeline( |
|
day_data, |
|
x_start="Start", |
|
x_end="Finish", |
|
y="Task", |
|
color="color", |
|
color_discrete_map={ |
|
"on_time": "green", |
|
"late": "orange", |
|
"unassigned": "red" |
|
}, |
|
hover_data=["customer_name", "vehicle_id", "driver_name", "priority", "time_window", |
|
"estimated_arrival_time", "weight_kg", "assigned"] |
|
) |
|
|
|
|
|
for i, row in day_data.iterrows(): |
|
|
|
if row['assigned']: |
|
for trace in fig.data: |
|
|
|
color_value = row['color'] |
|
|
|
|
|
if trace.name == color_value and any(y == row['Task'] for y in trace.y): |
|
|
|
if 'marker' not in trace: |
|
trace.marker = dict() |
|
if 'pattern' not in trace.marker: |
|
trace.marker.pattern = dict( |
|
shape="\\", |
|
size=4, |
|
solidity=0.5, |
|
fgcolor="black" |
|
) |
|
|
|
|
|
for idx, row in day_data.iterrows(): |
|
status_text = "β On-time" if row['on_time'] and row['assigned'] else "β Late" if row['assigned'] else "Not assigned" |
|
position = (row['Start'] + (row['Finish'] - row['Start'])/2) |
|
|
|
|
|
if row['assigned']: |
|
fig.add_annotation( |
|
x=position, |
|
y=row['Task'], |
|
text=status_text, |
|
showarrow=False, |
|
font=dict(color="black", size=10), |
|
xanchor="center" |
|
) |
|
|
|
|
|
fig.update_layout( |
|
title=f"Deliveries by Customer - {date.strftime('%b %d, %Y')}", |
|
xaxis_title="Time of Day", |
|
yaxis_title="Delivery", |
|
height=max(300, 50 * len(day_data)), |
|
yaxis={'categoryorder':'category ascending'}, |
|
showlegend=False |
|
) |
|
|
|
|
|
st.plotly_chart(fig, use_container_width=True) |
|
|
|
|
|
col1, col2, col3, col4 = st.columns(4) |
|
with col1: |
|
st.metric("Total Deliveries", len(day_data)) |
|
with col2: |
|
st.metric("On-Time Deliveries", len(day_data[day_data['on_time']])) |
|
with col3: |
|
st.metric("Late Deliveries", len(day_data[~day_data['on_time']])) |
|
with col4: |
|
if 'weight_kg' in day_data.columns: |
|
st.metric("Total Weight", f"{day_data['weight_kg'].sum():.2f} kg") |
|
|
|
|
|
if 'priority' in day_data.columns: |
|
st.write("##### Deliveries by Priority") |
|
priority_counts = day_data['priority'].value_counts() |
|
priority_cols = st.columns(min(4, len(priority_counts))) |
|
|
|
for j, (priority, count) in enumerate(priority_counts.items()): |
|
col_idx = j % len(priority_cols) |
|
with priority_cols[col_idx]: |
|
st.metric(priority, count) |
|
|
|
|
|
st.write("#### Vehicle Schedule") |
|
|
|
|
|
fig_vehicle = px.timeline( |
|
day_data, |
|
x_start="Start", |
|
x_end="Finish", |
|
y="Vehicle Task", |
|
color="on_time", |
|
color_discrete_map={True: "green", False: "red"}, |
|
hover_data=["delivery_id", "customer_name", "priority", "time_window", |
|
"estimated_arrival_time", "weight_kg"] |
|
) |
|
|
|
|
|
for idx, row in day_data.iterrows(): |
|
fig_vehicle.add_annotation( |
|
x=(row['Start'] + (row['Finish'] - row['Start'])/2), |
|
y=row['Vehicle Task'], |
|
text=f"#{row['delivery_id']}", |
|
showarrow=False, |
|
font=dict(size=10, color="black") |
|
) |
|
|
|
|
|
fig_vehicle.update_layout( |
|
title=f"Vehicle Assignment Schedule - {date.strftime('%b %d, %Y')}", |
|
xaxis_title="Time of Day", |
|
yaxis_title="Vehicle", |
|
height=max(300, 70 * day_data['Vehicle Task'].nunique()), |
|
yaxis={'categoryorder':'category ascending'} |
|
) |
|
|
|
|
|
st.plotly_chart(fig_vehicle, use_container_width=True) |
|
|
|
|
|
st.write("##### Vehicle Utilization") |
|
|
|
|
|
vehicle_metrics = [] |
|
for vehicle_id in day_data['vehicle_id'].unique(): |
|
vehicle_deliveries = day_data[day_data['vehicle_id'] == vehicle_id] |
|
|
|
|
|
total_mins = sum((row['Finish'] - row['Start']).total_seconds() / 60 for _, row in vehicle_deliveries.iterrows()) |
|
|
|
|
|
on_time_count = len(vehicle_deliveries[vehicle_deliveries['on_time'] == True]) |
|
|
|
|
|
driver_name = vehicle_deliveries['driver_name'].iloc[0] if not vehicle_deliveries.empty else "Unknown" |
|
|
|
vehicle_metrics.append({ |
|
'vehicle_id': vehicle_id, |
|
'driver_name': driver_name, |
|
'deliveries': len(vehicle_deliveries), |
|
'delivery_time_mins': total_mins, |
|
'on_time_deliveries': on_time_count, |
|
'on_time_percentage': (on_time_count / len(vehicle_deliveries)) * 100 if len(vehicle_deliveries) > 0 else 0 |
|
}) |
|
|
|
|
|
metrics_df = pd.DataFrame(vehicle_metrics) |
|
|
|
|
|
st.dataframe(metrics_df.style.format({ |
|
'delivery_time_mins': '{:.0f}', |
|
'on_time_percentage': '{:.1f}%' |
|
})) |
|
|
|
else: |
|
st.info(f"No deliveries scheduled for {date.strftime('%b %d, %Y')}") |
|
else: |
|
st.info("No calendar data available. Please generate routes first.") |
|
|
|
def create_distance_matrix(locations): |
|
""" |
|
Create a simple Euclidean distance matrix between locations |
|
|
|
In a real implementation, this would be replaced by actual road distances |
|
|
|
Parameters: |
|
locations (list): List of location dictionaries with lat and lon |
|
|
|
Returns: |
|
numpy.ndarray: Distance matrix |
|
""" |
|
n = len(locations) |
|
matrix = np.zeros((n, n)) |
|
for i in range(n): |
|
for j in range(n): |
|
if i == j: |
|
continue |
|
|
|
|
|
lat1, lon1 = locations[i]['latitude'], locations[i]['longitude'] |
|
lat2, lon2 = locations[j]['latitude'], locations[j]['longitude'] |
|
|
|
|
|
|
|
dist = ((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2) ** 0.5 * 111 |
|
matrix[i, j] = dist |
|
|
|
return matrix |
|
|
|
def get_road_route(start_point, end_point): |
|
""" |
|
Get a route that follows actual roads between two points using OpenStreetMap's routing service. |
|
|
|
Args: |
|
start_point: (lat, lon) tuple of start location |
|
end_point: (lat, lon) tuple of end location |
|
|
|
Returns: |
|
list: List of (lat, lon) points representing the actual road route |
|
""" |
|
try: |
|
|
|
start_lat, start_lon = start_point |
|
end_lat, end_lon = end_point |
|
|
|
|
|
url = f"http://router.project-osrm.org/route/v1/driving/{start_lon},{start_lat};{end_lon},{end_lat}" |
|
params = { |
|
"overview": "full", |
|
"geometries": "geojson", |
|
"steps": "true" |
|
} |
|
|
|
|
|
with st.spinner(f"Getting route from ({start_lat:.4f}, {start_lon:.4f}) to ({end_lat:.4f}, {end_lon:.4f})..."): |
|
response = requests.get(url, params=params, timeout=5) |
|
|
|
if response.status_code == 200: |
|
data = response.json() |
|
|
|
|
|
if data['code'] == 'Ok' and len(data['routes']) > 0: |
|
|
|
geometry = data['routes'][0]['geometry']['coordinates'] |
|
|
|
|
|
route_points = [(lon, lat) for lat, lon in geometry] |
|
return route_points |
|
|
|
|
|
st.warning(f"Could not get road route: {response.status_code} - {response.text if response.status_code != 200 else 'No routes found'}") |
|
|
|
except Exception as e: |
|
st.warning(f"Error getting road route: {str(e)}") |
|
|
|
|
|
with st.spinner("Generating approximate route..."): |
|
|
|
start_lat, start_lon = start_point |
|
end_lat, end_lon = end_point |
|
|
|
|
|
direct_dist = ((start_lat - end_lat)**2 + (start_lon - end_lon)**2)**0.5 |
|
|
|
|
|
num_points = max(10, int(direct_dist * 10000)) |
|
|
|
|
|
route_points = [] |
|
|
|
|
|
route_points.append((start_lat, start_lon)) |
|
|
|
|
|
|
|
mid_lat = (start_lat + end_lat) / 2 |
|
mid_lon = (start_lon + end_lon) / 2 |
|
|
|
|
|
|
|
dx = end_lat - start_lat |
|
dy = end_lon - start_lon |
|
|
|
|
|
perpendicular_x = -dy |
|
perpendicular_y = dx |
|
|
|
|
|
magnitude = (perpendicular_x**2 + perpendicular_y**2)**0.5 |
|
if magnitude > 0: |
|
perpendicular_x /= magnitude |
|
perpendicular_y /= magnitude |
|
|
|
|
|
offset_scale = direct_dist * 0.2 |
|
|
|
|
|
mid_lat += perpendicular_x * offset_scale * random.choice([-1, 1]) |
|
mid_lon += perpendicular_y * offset_scale * random.choice([-1, 1]) |
|
|
|
|
|
for i in range(1, num_points // 2): |
|
t = i / (num_points // 2) |
|
|
|
u = 1 - t |
|
lat = u**2 * start_lat + 2 * u * t * mid_lat + t**2 * mid_lat |
|
lon = u**2 * start_lon + 2 * u * t * mid_lon + t**2 * mid_lon |
|
|
|
|
|
noise_scale = 0.0002 * direct_dist |
|
lat += random.uniform(-noise_scale, noise_scale) |
|
lon += random.uniform(-noise_scale, noise_scale) |
|
|
|
route_points.append((lat, lon)) |
|
|
|
|
|
for i in range(num_points // 2, num_points): |
|
t = (i - num_points // 2) / (num_points // 2) |
|
|
|
u = 1 - t |
|
lat = u**2 * mid_lat + 2 * u * t * mid_lat + t**2 * end_lat |
|
lon = u**2 * mid_lon + 2 * u * t * mid_lon + t**2 * end_lon |
|
|
|
|
|
noise_scale = 0.0002 * direct_dist |
|
lat += random.uniform(-noise_scale, noise_scale) |
|
lon += random.uniform(-noise_scale, noise_scale) |
|
|
|
route_points.append((lat, lon)) |
|
|
|
|
|
route_points.append((end_lat, end_lon)) |
|
|
|
return route_points |
|
|
|
|
|
if __name__ == "__main__": |
|
st.set_page_config( |
|
page_title="Route Optimizer - Delivery Route Optimization", |
|
page_icon="π£οΈ", |
|
layout="wide" |
|
) |
|
optimize_page() |