From 1d272f3c678a1a5f052fc690197951aad43c9257 Mon Sep 17 00:00:00 2001 From: "Yin, Zhuoli" Date: Mon, 23 Mar 2026 00:28:46 -0500 Subject: [PATCH] Add e-commerce parcel delivery optimization module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements delivery routing with three depot strategies (Amazon DS via K-means clustering, Walmart Supercenter nearest-assignment, and local N×N grid-based delivery) and TSP solving via OR-Tools and Gurobi MIP with MTZ subtour elimination. Includes route visualization and VMT computation using Euclidean distance. Co-Authored-By: Claude Opus 4.6 --- ecommerce_delivery/__init__.py | 2 + ecommerce_delivery/data_processing.py | 62 +++++ ecommerce_delivery/depot_strategies.py | 240 ++++++++++++++++++ ecommerce_delivery/main.py | 163 +++++++++++++ ecommerce_delivery/tsp_solver.py | 311 ++++++++++++++++++++++++ ecommerce_delivery/visualization.py | 178 ++++++++++++++ ecommerce_delivery/walmart_locations.py | 98 ++++++++ 7 files changed, 1054 insertions(+) create mode 100644 ecommerce_delivery/__init__.py create mode 100644 ecommerce_delivery/data_processing.py create mode 100644 ecommerce_delivery/depot_strategies.py create mode 100644 ecommerce_delivery/main.py create mode 100644 ecommerce_delivery/tsp_solver.py create mode 100644 ecommerce_delivery/visualization.py create mode 100644 ecommerce_delivery/walmart_locations.py diff --git a/ecommerce_delivery/__init__.py b/ecommerce_delivery/__init__.py new file mode 100644 index 0000000..d6674e3 --- /dev/null +++ b/ecommerce_delivery/__init__.py @@ -0,0 +1,2 @@ +# E-commerce Delivery Modeling and Optimization +# Supports: parcel delivery (current), food delivery (future) diff --git a/ecommerce_delivery/data_processing.py b/ecommerce_delivery/data_processing.py new file mode 100644 index 0000000..4f0e3c7 --- /dev/null +++ b/ecommerce_delivery/data_processing.py @@ -0,0 +1,62 @@ +""" +Data processing module for e-commerce delivery modeling. +Loads shopping location CSV and prepares data for delivery optimization. +""" + +import pandas as pd +import numpy as np + + +def load_shopping_data(csv_path: str) -> pd.DataFrame: + """Load shopping locations CSV and return a cleaned DataFrame.""" + df = pd.read_csv(csv_path) + required_cols = ['ShopLat', 'ShopLon', 'DeliveryLat', 'DeliveryLon'] + for col in required_cols: + if col not in df.columns: + raise ValueError(f"Missing required column: {col}") + # Drop rows with missing coordinates + df = df.dropna(subset=required_cols).reset_index(drop=True) + return df + + +def euclidean_distance(lat1, lon1, lat2, lon2): + """ + Compute Euclidean (straight-line) distance between two points + using a flat-earth approximation in miles. + At ~39.8N latitude: 1 degree lat ≈ 69.0 miles, 1 degree lon ≈ 53.4 miles. + """ + lat_miles = 69.0 + lon_miles = 53.4 # approximate for Indianapolis latitude + dy = (lat2 - lat1) * lat_miles + dx = (lon2 - lon1) * lon_miles + return np.sqrt(dx**2 + dy**2) + + +def build_distance_matrix(lats, lons): + """ + Build a symmetric Euclidean distance matrix for a set of locations. + + Parameters + ---------- + lats : array-like of latitudes + lons : array-like of longitudes + + Returns + ------- + dist_matrix : np.ndarray of shape (n, n) + """ + n = len(lats) + lats = np.array(lats, dtype=float) + lons = np.array(lons, dtype=float) + dist_matrix = np.zeros((n, n)) + for i in range(n): + for j in range(i + 1, n): + d = euclidean_distance(lats[i], lons[i], lats[j], lons[j]) + dist_matrix[i, j] = d + dist_matrix[j, i] = d + return dist_matrix + + +def compute_num_trucks(num_items: int, vehicle_capacity: int = 50) -> int: + """Compute the number of trucks needed given item count and capacity.""" + return int(np.ceil(num_items / vehicle_capacity)) diff --git a/ecommerce_delivery/depot_strategies.py b/ecommerce_delivery/depot_strategies.py new file mode 100644 index 0000000..2b041de --- /dev/null +++ b/ecommerce_delivery/depot_strategies.py @@ -0,0 +1,240 @@ +""" +Depot determination strategies for e-commerce delivery. + +Three strategies: +1. Amazon Delivery Station: K-means clustering on shopping locations, + depot = centroid of each cluster. K = number of trucks. +2. Walmart Supercenter: Assign shopping locations to nearest existing + Walmart Supercenter. Supports both shopping-location-oriented and + home-location-oriented assignment. +3. Local Delivery Company: Divide map into N×N grid, depot = centroid + of each grid cell. Assign home locations within each grid cell. +""" + +import numpy as np +import pandas as pd +from sklearn.cluster import KMeans + +from .data_processing import euclidean_distance, compute_num_trucks +from .walmart_locations import get_walmart_supercenters + + +def amazon_ds_strategy(df: pd.DataFrame, vehicle_capacity: int = 50): + """ + Amazon Delivery Station strategy. + + Cluster shopping locations into K groups (K = number of trucks needed). + The depot for each group is the centroid of the cluster. + + Parameters + ---------- + df : DataFrame with ShopLat, ShopLon, DeliveryLat, DeliveryLon columns + vehicle_capacity : max items per truck + + Returns + ------- + list of dicts, each with: + - depot_lat, depot_lon: centroid of cluster + - delivery_locations: list of (lat, lon) home locations + - shop_locations: list of (lat, lon) shop locations + - indices: original DataFrame indices + """ + num_items = len(df) + K = compute_num_trucks(num_items, vehicle_capacity) + + shop_coords = df[['ShopLat', 'ShopLon']].values + kmeans = KMeans(n_clusters=K, random_state=42, n_init=10) + labels = kmeans.fit_predict(shop_coords) + + routes = [] + for k in range(K): + mask = labels == k + cluster_df = df[mask] + centroid = kmeans.cluster_centers_[k] + routes.append({ + 'depot_lat': centroid[0], + 'depot_lon': centroid[1], + 'delivery_locations': list(zip( + cluster_df['DeliveryLat'].values, + cluster_df['DeliveryLon'].values + )), + 'shop_locations': list(zip( + cluster_df['ShopLat'].values, + cluster_df['ShopLon'].values + )), + 'indices': cluster_df.index.tolist(), + 'strategy': 'amazon_ds', + 'cluster_id': k, + }) + return routes + + +def walmart_strategy(df: pd.DataFrame, assignment_mode: str = 'shopping', + vehicle_capacity: int = 50, custom_locations=None): + """ + Walmart Supercenter strategy. + + Assign shopping/home locations to the nearest Walmart Supercenter. + Each supercenter becomes a depot. If a depot has more items than + vehicle_capacity, split into multiple routes. + + Parameters + ---------- + df : DataFrame with ShopLat, ShopLon, DeliveryLat, DeliveryLon columns + assignment_mode : 'shopping' (assign by shop location) or 'home' (assign by home location) + vehicle_capacity : max items per truck + custom_locations : optional list of dicts with lat, lon, name + + Returns + ------- + list of route dicts (same structure as amazon_ds_strategy) + """ + walmarts = custom_locations if custom_locations else get_walmart_supercenters() + walmart_lats = np.array([w['lat'] for w in walmarts]) + walmart_lons = np.array([w['lon'] for w in walmarts]) + + if assignment_mode == 'shopping': + ref_lats = df['ShopLat'].values + ref_lons = df['ShopLon'].values + elif assignment_mode == 'home': + ref_lats = df['DeliveryLat'].values + ref_lons = df['DeliveryLon'].values + else: + raise ValueError(f"assignment_mode must be 'shopping' or 'home', got '{assignment_mode}'") + + # Assign each item to the nearest Walmart + assignments = [] + for i in range(len(df)): + dists = [ + euclidean_distance(ref_lats[i], ref_lons[i], wlat, wlon) + for wlat, wlon in zip(walmart_lats, walmart_lons) + ] + assignments.append(np.argmin(dists)) + + df = df.copy() + df['walmart_idx'] = assignments + + routes = [] + for w_idx in df['walmart_idx'].unique(): + w_df = df[df['walmart_idx'] == w_idx] + walmart = walmarts[w_idx] + + # Split into sub-routes if exceeding vehicle capacity + num_sub_routes = compute_num_trucks(len(w_df), vehicle_capacity) + indices = w_df.index.tolist() + + for sub in range(num_sub_routes): + start = sub * vehicle_capacity + end = min((sub + 1) * vehicle_capacity, len(w_df)) + sub_indices = indices[start:end] + sub_df = df.loc[sub_indices] + + routes.append({ + 'depot_lat': walmart['lat'], + 'depot_lon': walmart['lon'], + 'depot_name': walmart.get('name', f'Walmart #{w_idx}'), + 'delivery_locations': list(zip( + sub_df['DeliveryLat'].values, + sub_df['DeliveryLon'].values + )), + 'shop_locations': list(zip( + sub_df['ShopLat'].values, + sub_df['ShopLon'].values + )), + 'indices': sub_indices, + 'strategy': 'walmart', + 'walmart_idx': w_idx, + 'sub_route': sub, + }) + return routes + + +def local_delivery_strategy(df: pd.DataFrame, N: int = 5, + vehicle_capacity: int = 50): + """ + Local delivery company strategy. + + Divide the map into N×N grid cells. The depot for each cell is + the centroid of the cell. Home locations within each cell are + assigned to that cell's depot. + + Parameters + ---------- + df : DataFrame with DeliveryLat, DeliveryLon columns + N : grid size (N×N) + vehicle_capacity : max items per truck + + Returns + ------- + list of route dicts + """ + lat_min = df['DeliveryLat'].min() + lat_max = df['DeliveryLat'].max() + lon_min = df['DeliveryLon'].min() + lon_max = df['DeliveryLon'].max() + + # Add small buffer to include boundary points + lat_step = (lat_max - lat_min) / N + lon_step = (lon_max - lon_min) / N + + if lat_step == 0 or lon_step == 0: + raise ValueError("All delivery locations have the same coordinates.") + + # Assign each home to a grid cell + df = df.copy() + df['grid_row'] = np.clip( + ((df['DeliveryLat'] - lat_min) / lat_step).astype(int), 0, N - 1 + ) + df['grid_col'] = np.clip( + ((df['DeliveryLon'] - lon_min) / lon_step).astype(int), 0, N - 1 + ) + df['grid_id'] = df['grid_row'] * N + df['grid_col'] + + routes = [] + for grid_id in df['grid_id'].unique(): + g_df = df[df['grid_id'] == grid_id] + row = g_df['grid_row'].iloc[0] + col = g_df['grid_col'].iloc[0] + + # Centroid of grid cell + depot_lat = lat_min + (row + 0.5) * lat_step + depot_lon = lon_min + (col + 0.5) * lon_step + + # Split into sub-routes if exceeding capacity + num_sub_routes = compute_num_trucks(len(g_df), vehicle_capacity) + indices = g_df.index.tolist() + + for sub in range(num_sub_routes): + start = sub * vehicle_capacity + end = min((sub + 1) * vehicle_capacity, len(g_df)) + sub_indices = indices[start:end] + sub_df = df.loc[sub_indices] + + routes.append({ + 'depot_lat': depot_lat, + 'depot_lon': depot_lon, + 'delivery_locations': list(zip( + sub_df['DeliveryLat'].values, + sub_df['DeliveryLon'].values + )), + 'shop_locations': list(zip( + sub_df['ShopLat'].values, + sub_df['ShopLon'].values + )), + 'indices': sub_indices, + 'strategy': 'local_delivery', + 'grid_id': grid_id, + 'grid_row': row, + 'grid_col': col, + 'sub_route': sub, + }) + + # Grid info for visualization + grid_info = { + 'N': N, + 'lat_min': lat_min, 'lat_max': lat_max, + 'lon_min': lon_min, 'lon_max': lon_max, + 'lat_step': lat_step, 'lon_step': lon_step, + } + + return routes, grid_info diff --git a/ecommerce_delivery/main.py b/ecommerce_delivery/main.py new file mode 100644 index 0000000..53d0739 --- /dev/null +++ b/ecommerce_delivery/main.py @@ -0,0 +1,163 @@ +""" +Main entry point for e-commerce parcel delivery optimization. + +Usage: + python -m ecommerce_delivery.main --csv shopping_locations.csv --strategy amazon + python -m ecommerce_delivery.main --csv shopping_locations.csv --strategy walmart --assignment shopping + python -m ecommerce_delivery.main --csv shopping_locations.csv --strategy local --grid-size 5 + python -m ecommerce_delivery.main --csv shopping_locations.csv --strategy all +""" + +import argparse +import os +import sys + +from .data_processing import load_shopping_data, compute_num_trucks +from .depot_strategies import ( + amazon_ds_strategy, + walmart_strategy, + local_delivery_strategy, +) +from .tsp_solver import solve_all_routes +from .visualization import ( + plot_all_routes, + plot_grid_overlay, + print_route_summary, +) + + +def run_amazon(df, vehicle_capacity, solver, time_limit, mip_gap, output_dir, verbose): + """Run Amazon DS strategy.""" + print("\n" + "=" * 50) + print("AMAZON DELIVERY STATION STRATEGY") + print("=" * 50) + + routes = amazon_ds_strategy(df, vehicle_capacity=vehicle_capacity) + print(f"Created {len(routes)} delivery routes " + f"(capacity={vehicle_capacity}, items={len(df)})") + + results = solve_all_routes(routes, solver=solver, time_limit=time_limit, + mip_gap=mip_gap, verbose=verbose) + print_route_summary(results) + + save_path = os.path.join(output_dir, 'amazon_ds_routes.png') if output_dir else None + plot_all_routes(results, strategy_name='Amazon Delivery Station', + save_path=save_path) + return results + + +def run_walmart(df, vehicle_capacity, assignment_mode, solver, time_limit, + mip_gap, output_dir, verbose): + """Run Walmart Supercenter strategy.""" + print("\n" + "=" * 50) + print(f"WALMART SUPERCENTER STRATEGY (assignment={assignment_mode})") + print("=" * 50) + + routes = walmart_strategy(df, assignment_mode=assignment_mode, + vehicle_capacity=vehicle_capacity) + print(f"Created {len(routes)} delivery routes " + f"(capacity={vehicle_capacity}, items={len(df)})") + + results = solve_all_routes(routes, solver=solver, time_limit=time_limit, + mip_gap=mip_gap, verbose=verbose) + print_route_summary(results) + + save_path = os.path.join(output_dir, f'walmart_routes_{assignment_mode}.png') if output_dir else None + plot_all_routes(results, strategy_name=f'Walmart ({assignment_mode})', + save_path=save_path) + return results + + +def run_local(df, vehicle_capacity, grid_size, solver, time_limit, mip_gap, + output_dir, verbose): + """Run Local delivery company strategy.""" + print("\n" + "=" * 50) + print(f"LOCAL DELIVERY STRATEGY ({grid_size}x{grid_size} grid)") + print("=" * 50) + + routes, grid_info = local_delivery_strategy( + df, N=grid_size, vehicle_capacity=vehicle_capacity + ) + print(f"Created {len(routes)} delivery routes " + f"(capacity={vehicle_capacity}, items={len(df)})") + + results = solve_all_routes(routes, solver=solver, time_limit=time_limit, + mip_gap=mip_gap, verbose=verbose) + print_route_summary(results) + + save_path = os.path.join(output_dir, f'local_routes_{grid_size}x{grid_size}.png') if output_dir else None + plot_grid_overlay(results, grid_info, save_path=save_path) + return results + + +def main(): + parser = argparse.ArgumentParser( + description='E-commerce parcel delivery optimization' + ) + parser.add_argument('--csv', type=str, required=True, + help='Path to shopping locations CSV file') + parser.add_argument('--strategy', type=str, default='all', + choices=['amazon', 'walmart', 'local', 'all'], + help='Depot strategy to use') + parser.add_argument('--vehicle-capacity', type=int, default=50, + help='Max items per delivery truck (default: 50)') + parser.add_argument('--assignment', type=str, default='shopping', + choices=['shopping', 'home'], + help='Walmart assignment mode (default: shopping)') + parser.add_argument('--grid-size', type=int, default=5, + help='Grid size N for local delivery (default: 5)') + parser.add_argument('--solver', type=str, default='ortools', + choices=['ortools', 'gurobi'], + help='TSP solver backend (default: ortools)') + parser.add_argument('--time-limit', type=int, default=60, + help='Solver time limit per route in seconds (default: 60)') + parser.add_argument('--mip-gap', type=float, default=0.01, + help='Gurobi MIP gap tolerance (default: 0.01)') + parser.add_argument('--output-dir', type=str, default=None, + help='Directory to save output figures') + parser.add_argument('--verbose', action='store_true', + help='Show solver output') + parser.add_argument('--no-plot', action='store_true', + help='Skip displaying plots (still saves if output-dir set)') + + args = parser.parse_args() + + # Load data + print(f"Loading data from {args.csv}...") + df = load_shopping_data(args.csv) + print(f"Loaded {len(df)} shopping/delivery records") + print(f"Number of trucks needed: {compute_num_trucks(len(df), args.vehicle_capacity)}") + + # Create output directory if specified + if args.output_dir: + os.makedirs(args.output_dir, exist_ok=True) + + all_results = {} + + if args.strategy in ('amazon', 'all'): + all_results['amazon'] = run_amazon( + df, args.vehicle_capacity, args.solver, args.time_limit, + args.mip_gap, args.output_dir, args.verbose + ) + + if args.strategy in ('walmart', 'all'): + all_results['walmart'] = run_walmart( + df, args.vehicle_capacity, args.assignment, args.solver, + args.time_limit, args.mip_gap, args.output_dir, args.verbose + ) + + if args.strategy in ('local', 'all'): + all_results['local'] = run_local( + df, args.vehicle_capacity, args.grid_size, args.solver, + args.time_limit, args.mip_gap, args.output_dir, args.verbose + ) + + if not args.no_plot: + import matplotlib.pyplot as plt + plt.show() + + return all_results + + +if __name__ == '__main__': + main() diff --git a/ecommerce_delivery/tsp_solver.py b/ecommerce_delivery/tsp_solver.py new file mode 100644 index 0000000..20a9b55 --- /dev/null +++ b/ecommerce_delivery/tsp_solver.py @@ -0,0 +1,311 @@ +""" +TSP solver with multiple backends: + 1. Gurobi MIP (MTZ subtour elimination) - exact, requires license + 2. OR-Tools (Google) - free, high-quality heuristic/metaheuristic + +The TSP is: depot -> delivery_location_1 -> ... -> delivery_location_n -> depot +minimizing total Euclidean distance. +""" + +import numpy as np +from .data_processing import build_distance_matrix, euclidean_distance + + +# --------------------------------------------------------------------------- +# Trivial case handler +# --------------------------------------------------------------------------- + +def _handle_trivial(depot_lat, depot_lon, delivery_locations): + """Return result dict for 0 or 1 delivery locations, or None.""" + n = len(delivery_locations) + if n == 0: + return { + 'route': [0], + 'route_coords': [(depot_lat, depot_lon)], + 'total_distance': 0.0, + 'status': 'OPTIMAL', + } + if n == 1: + dlat, dlon = delivery_locations[0] + d = euclidean_distance(depot_lat, depot_lon, dlat, dlon) + return { + 'route': [0, 1, 0], + 'route_coords': [(depot_lat, depot_lon), (dlat, dlon), + (depot_lat, depot_lon)], + 'total_distance': 2 * d, + 'status': 'OPTIMAL', + } + return None + + +# --------------------------------------------------------------------------- +# OR-Tools solver (default) +# --------------------------------------------------------------------------- + +def solve_tsp_ortools(depot_lat, depot_lon, delivery_locations, + time_limit=30, verbose=False): + """ + Solve TSP using Google OR-Tools routing solver. + + Parameters + ---------- + depot_lat, depot_lon : float + delivery_locations : list of (lat, lon) + time_limit : int – solver time limit in seconds + verbose : bool + + Returns + ------- + dict with route, route_coords, total_distance, status + """ + from ortools.constraint_solver import routing_enums_pb2, pywrapcp + + trivial = _handle_trivial(depot_lat, depot_lon, delivery_locations) + if trivial is not None: + return trivial + + all_lats = [depot_lat] + [loc[0] for loc in delivery_locations] + all_lons = [depot_lon] + [loc[1] for loc in delivery_locations] + n = len(all_lats) + + dist = build_distance_matrix(all_lats, all_lons) + # OR-Tools needs integer distances; scale to 0.001-mile precision + SCALE = 1000 + int_dist = (dist * SCALE).astype(int) + + manager = pywrapcp.RoutingIndexManager(n, 1, 0) # 1 vehicle, depot=0 + routing = pywrapcp.RoutingModel(manager) + + def distance_callback(from_index, to_index): + from_node = manager.IndexToNode(from_index) + to_node = manager.IndexToNode(to_index) + return int_dist[from_node][to_node] + + transit_callback_index = routing.RegisterTransitCallback(distance_callback) + routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index) + + search_parameters = pywrapcp.DefaultRoutingSearchParameters() + search_parameters.first_solution_strategy = ( + routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC + ) + search_parameters.local_search_metaheuristic = ( + routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH + ) + search_parameters.time_limit.seconds = time_limit + if verbose: + search_parameters.log_search = True + + solution = routing.SolveWithParameters(search_parameters) + + if solution: + tour = [] + index = routing.Start(0) + while not routing.IsEnd(index): + tour.append(manager.IndexToNode(index)) + index = solution.Value(routing.NextVar(index)) + tour.append(0) # return to depot + + total_distance = solution.ObjectiveValue() / SCALE + route_coords = [(all_lats[i], all_lons[i]) for i in tour] + status = 'OPTIMAL' + else: + # Fallback: sequential tour + tour = list(range(n)) + [0] + route_coords = [(all_lats[i], all_lons[i]) for i in tour] + total_distance = sum( + dist[tour[i]][tour[i + 1]] for i in range(len(tour) - 1) + ) + status = 'NO_SOLUTION' + + return { + 'route': tour, + 'route_coords': route_coords, + 'total_distance': total_distance, + 'status': status, + } + + +# --------------------------------------------------------------------------- +# Gurobi MIP solver (exact, requires valid license) +# --------------------------------------------------------------------------- + +def solve_tsp_gurobi(depot_lat, depot_lon, delivery_locations, + time_limit=300, mip_gap=0.01, verbose=False): + """ + Solve TSP using Gurobi MIP with MTZ subtour elimination. + + Parameters + ---------- + depot_lat, depot_lon : float + delivery_locations : list of (lat, lon) + time_limit : int – Gurobi time limit in seconds + mip_gap : float – MIP gap tolerance + verbose : bool + + Returns + ------- + dict with route, route_coords, total_distance, status + """ + try: + import gurobipy as gp + from gurobipy import GRB + except ImportError: + raise ImportError("Gurobi required. Install with: pip install gurobipy") + + trivial = _handle_trivial(depot_lat, depot_lon, delivery_locations) + if trivial is not None: + return trivial + + all_lats = [depot_lat] + [loc[0] for loc in delivery_locations] + all_lons = [depot_lon] + [loc[1] for loc in delivery_locations] + n = len(all_lats) + + dist = build_distance_matrix(all_lats, all_lons) + + model = gp.Model("TSP") + if not verbose: + model.setParam('OutputFlag', 0) + model.setParam('TimeLimit', time_limit) + model.setParam('MIPGap', mip_gap) + + # Decision variables + x = {} + for i in range(n): + for j in range(n): + if i != j: + x[i, j] = model.addVar(vtype=GRB.BINARY, name=f'x_{i}_{j}') + + # MTZ position variables + u = {} + for i in range(1, n): + u[i] = model.addVar(lb=1, ub=n - 1, vtype=GRB.CONTINUOUS, name=f'u_{i}') + + model.update() + + # Objective + model.setObjective( + gp.quicksum(dist[i][j] * x[i, j] + for i in range(n) for j in range(n) if i != j), + GRB.MINIMIZE + ) + + # Degree constraints + for i in range(n): + model.addConstr( + gp.quicksum(x[i, j] for j in range(n) if j != i) == 1, + name=f'out_{i}' + ) + model.addConstr( + gp.quicksum(x[j, i] for j in range(n) if j != i) == 1, + name=f'in_{i}' + ) + + # MTZ subtour elimination + for i in range(1, n): + for j in range(1, n): + if i != j: + model.addConstr( + u[i] - u[j] + (n - 1) * x[i, j] <= n - 2, + name=f'mtz_{i}_{j}' + ) + + model.optimize() + + if model.status in (GRB.OPTIMAL, GRB.TIME_LIMIT) and model.SolCount > 0: + tour = [0] + current = 0 + for _ in range(n - 1): + for j in range(n): + if j != current and x[current, j].X > 0.5: + tour.append(j) + current = j + break + tour.append(0) + + route_coords = [(all_lats[i], all_lons[i]) for i in tour] + total_distance = model.ObjVal + + status_map = {GRB.OPTIMAL: 'OPTIMAL', GRB.TIME_LIMIT: 'TIME_LIMIT'} + status = status_map.get(model.status, f'STATUS_{model.status}') + else: + tour = list(range(n)) + [0] + route_coords = [(all_lats[i], all_lons[i]) for i in tour] + total_distance = sum( + dist[tour[i]][tour[i + 1]] for i in range(len(tour) - 1) + ) + status = 'INFEASIBLE_OR_NO_SOLUTION' + + return { + 'route': tour, + 'route_coords': route_coords, + 'total_distance': total_distance, + 'status': status, + } + + +# --------------------------------------------------------------------------- +# Unified interface +# --------------------------------------------------------------------------- + +def solve_tsp(depot_lat, depot_lon, delivery_locations, + solver='ortools', time_limit=60, mip_gap=0.01, verbose=False): + """ + Solve TSP with the specified solver backend. + + Parameters + ---------- + solver : str – 'ortools' (default) or 'gurobi' + """ + if solver == 'ortools': + return solve_tsp_ortools( + depot_lat, depot_lon, delivery_locations, + time_limit=time_limit, verbose=verbose, + ) + elif solver == 'gurobi': + return solve_tsp_gurobi( + depot_lat, depot_lon, delivery_locations, + time_limit=time_limit, mip_gap=mip_gap, verbose=verbose, + ) + else: + raise ValueError(f"Unknown solver: {solver}. Use 'ortools' or 'gurobi'.") + + +def solve_all_routes(routes, solver='ortools', time_limit=60, + mip_gap=0.01, verbose=False): + """ + Solve TSP for each delivery route. + + Parameters + ---------- + routes : list of route dicts from depot_strategies + solver : 'ortools' or 'gurobi' + time_limit : int + mip_gap : float (only for gurobi) + verbose : bool + + Returns + ------- + list of route dicts augmented with TSP solution fields + """ + results = [] + total_vmt = 0.0 + + for i, route in enumerate(routes): + print(f"Solving TSP for route {i + 1}/{len(routes)} " + f"({len(route['delivery_locations'])} deliveries)...") + + tsp_result = solve_tsp( + route['depot_lat'], route['depot_lon'], + route['delivery_locations'], + solver=solver, time_limit=time_limit, + mip_gap=mip_gap, verbose=verbose, + ) + + route_result = {**route, **tsp_result} + results.append(route_result) + total_vmt += tsp_result['total_distance'] + print(f" Route {i + 1}: {tsp_result['total_distance']:.2f} miles " + f"[{tsp_result['status']}]") + + print(f"\nTotal VMT across all routes: {total_vmt:.2f} miles") + return results diff --git a/ecommerce_delivery/visualization.py b/ecommerce_delivery/visualization.py new file mode 100644 index 0000000..4a415cd --- /dev/null +++ b/ecommerce_delivery/visualization.py @@ -0,0 +1,178 @@ +""" +Visualization module for delivery routes. +""" + +import matplotlib.pyplot as plt +import matplotlib.patches as mpatches +import numpy as np + + +def plot_route(route_result, ax=None, title=None, show_labels=False): + """ + Plot a single TSP route on a matplotlib axis. + + Parameters + ---------- + route_result : dict from tsp_solver with route_coords, depot_lat/lon, etc. + ax : matplotlib axis (created if None) + title : optional title string + show_labels : whether to label delivery points with indices + """ + if ax is None: + fig, ax = plt.subplots(1, 1, figsize=(8, 8)) + + coords = route_result['route_coords'] + lats = [c[0] for c in coords] + lons = [c[1] for c in coords] + + # Plot route edges + ax.plot(lons, lats, 'b-', linewidth=1.0, alpha=0.7, zorder=1) + + # Plot depot + ax.scatter( + route_result['depot_lon'], route_result['depot_lat'], + c='red', s=150, marker='s', zorder=3, edgecolors='black', + label='Depot' + ) + + # Plot delivery locations (skip depot at index 0 and last) + if len(route_result['delivery_locations']) > 0: + dlats = [loc[0] for loc in route_result['delivery_locations']] + dlons = [loc[1] for loc in route_result['delivery_locations']] + ax.scatter( + dlons, dlats, c='green', s=40, marker='o', zorder=2, + edgecolors='black', linewidths=0.5, label='Delivery' + ) + + if show_labels: + for idx, (dlat, dlon) in enumerate(zip(dlats, dlons)): + ax.annotate(str(idx + 1), (dlon, dlat), fontsize=7, + ha='center', va='bottom') + + ax.set_xlabel('Longitude') + ax.set_ylabel('Latitude') + if title: + ax.set_title(title) + else: + dist = route_result.get('total_distance', 0) + strategy = route_result.get('strategy', 'unknown') + ax.set_title(f'{strategy} | VMT: {dist:.2f} mi') + ax.legend(loc='best', fontsize=8) + ax.set_aspect('equal') + return ax + + +def plot_all_routes(route_results, strategy_name='', save_path=None): + """ + Plot all TSP routes on a single figure, each in a different color. + + Parameters + ---------- + route_results : list of route dicts with TSP solutions + strategy_name : string label for the strategy + save_path : optional file path to save the figure + """ + fig, ax = plt.subplots(1, 1, figsize=(12, 10)) + colors = plt.cm.tab20(np.linspace(0, 1, max(len(route_results), 1))) + + total_vmt = 0.0 + for i, route in enumerate(route_results): + coords = route['route_coords'] + lats = [c[0] for c in coords] + lons = [c[1] for c in coords] + + ax.plot(lons, lats, '-', color=colors[i % len(colors)], + linewidth=1.2, alpha=0.7, zorder=1) + + # Depot + ax.scatter( + route['depot_lon'], route['depot_lat'], + c='red', s=120, marker='s', zorder=3, edgecolors='black' + ) + + # Delivery locations + dlats = [loc[0] for loc in route['delivery_locations']] + dlons = [loc[1] for loc in route['delivery_locations']] + ax.scatter( + dlons, dlats, c=[colors[i % len(colors)]], s=30, marker='o', + zorder=2, edgecolors='black', linewidths=0.3 + ) + + total_vmt += route.get('total_distance', 0) + + # Legend + depot_patch = mpatches.Patch(color='red', label='Depot') + ax.legend(handles=[depot_patch], loc='upper right') + + ax.set_xlabel('Longitude') + ax.set_ylabel('Latitude') + ax.set_title( + f'{strategy_name} | {len(route_results)} routes | ' + f'Total VMT: {total_vmt:.2f} mi' + ) + ax.set_aspect('equal') + plt.tight_layout() + + if save_path: + fig.savefig(save_path, dpi=150, bbox_inches='tight') + print(f"Figure saved to {save_path}") + + return fig, ax + + +def plot_grid_overlay(route_results, grid_info, save_path=None): + """ + Plot local delivery routes with the N×N grid overlay. + + Parameters + ---------- + route_results : list of route dicts + grid_info : dict with N, lat_min, lat_max, lon_min, lon_max, lat_step, lon_step + save_path : optional file path to save the figure + """ + fig, ax = plot_all_routes(route_results, strategy_name='Local Delivery (Grid)') + + N = grid_info['N'] + lat_min = grid_info['lat_min'] + lon_min = grid_info['lon_min'] + lat_step = grid_info['lat_step'] + lon_step = grid_info['lon_step'] + + # Draw grid lines + for i in range(N + 1): + lat = lat_min + i * lat_step + ax.axhline(y=lat, color='gray', linestyle='--', linewidth=0.5, alpha=0.5) + for j in range(N + 1): + lon = lon_min + j * lon_step + ax.axvline(x=lon, color='gray', linestyle='--', linewidth=0.5, alpha=0.5) + + ax.set_title(f'Local Delivery ({N}×{N} Grid)') + + if save_path: + fig.savefig(save_path, dpi=150, bbox_inches='tight') + print(f"Figure saved to {save_path}") + + return fig, ax + + +def print_route_summary(route_results): + """Print a tabular summary of all routes.""" + print(f"\n{'='*70}") + print(f"{'Route':>6} | {'Deliveries':>10} | {'VMT (mi)':>10} | {'Status':>12} | Details") + print(f"{'-'*70}") + + total_vmt = 0.0 + total_deliveries = 0 + for i, r in enumerate(route_results): + n_del = len(r['delivery_locations']) + vmt = r.get('total_distance', 0) + status = r.get('status', 'N/A') + detail = r.get('depot_name', r.get('strategy', '')) + print(f"{i+1:>6} | {n_del:>10} | {vmt:>10.2f} | {status:>12} | {detail}") + total_vmt += vmt + total_deliveries += n_del + + print(f"{'-'*70}") + print(f"{'TOTAL':>6} | {total_deliveries:>10} | {total_vmt:>10.2f} |") + print(f"{'='*70}") + return total_vmt diff --git a/ecommerce_delivery/walmart_locations.py b/ecommerce_delivery/walmart_locations.py new file mode 100644 index 0000000..673ad1d --- /dev/null +++ b/ecommerce_delivery/walmart_locations.py @@ -0,0 +1,98 @@ +""" +Helper module for parsing Walmart Supercenter locations in Indianapolis. +Provides hardcoded locations and a Google Maps parsing utility. +""" + +import re +import json +import urllib.request +import urllib.parse + +# Hardcoded Walmart Supercenter locations in Indianapolis metro area +# Source: publicly available store locator data +WALMART_SUPERCENTERS_INDIANAPOLIS = [ + {"name": "Walmart Supercenter - W 10th St", "lat": 39.7713, "lon": -86.2080}, + {"name": "Walmart Supercenter - Lafayette Rd", "lat": 39.8226, "lon": -86.2195}, + {"name": "Walmart Supercenter - E Stop 11 Rd", "lat": 39.6507, "lon": -86.0887}, + {"name": "Walmart Supercenter - S Emerson Ave", "lat": 39.6778, "lon": -86.0680}, + {"name": "Walmart Supercenter - Rockville Rd", "lat": 39.7655, "lon": -86.3019}, + {"name": "Walmart Supercenter - E 96th St (Fishers)", "lat": 39.9269, "lon": -86.0028}, + {"name": "Walmart Supercenter - US 31 S (Greenwood)", "lat": 39.5974, "lon": -86.1140}, + {"name": "Walmart Supercenter - N High School Rd", "lat": 39.8253, "lon": -86.2847}, + {"name": "Walmart Supercenter - E Washington St", "lat": 39.7694, "lon": -86.0168}, + {"name": "Walmart Supercenter - Pendleton Pike", "lat": 39.8335, "lon": -85.9716}, + {"name": "Walmart Supercenter - S US 31 (Indianapolis)", "lat": 39.6725, "lon": -86.1312}, + {"name": "Walmart Supercenter - W 86th St (Brownsburg area)", "lat": 39.8512, "lon": -86.3371}, +] + + +def get_walmart_supercenters(): + """Return list of Walmart Supercenter locations as dicts with name, lat, lon.""" + return WALMART_SUPERCENTERS_INDIANAPOLIS.copy() + + +def parse_walmart_from_google_maps_url(url: str): + """ + Parse a Google Maps URL to extract location coordinates. + Supports URLs like: + https://www.google.com/maps/place/.../@39.7713,-86.208,17z/... + https://maps.google.com/?q=39.7713,-86.208 + + Returns + ------- + dict with 'lat' and 'lon', or None if parsing fails. + """ + # Try @lat,lon pattern + match = re.search(r'@(-?\d+\.\d+),(-?\d+\.\d+)', url) + if match: + return {"lat": float(match.group(1)), "lon": float(match.group(2))} + + # Try ?q=lat,lon pattern + match = re.search(r'[?&]q=(-?\d+\.\d+),(-?\d+\.\d+)', url) + if match: + return {"lat": float(match.group(1)), "lon": float(match.group(2))} + + return None + + +def add_custom_walmart_location(name: str, lat: float, lon: float): + """Add a custom Walmart Supercenter location to the list.""" + WALMART_SUPERCENTERS_INDIANAPOLIS.append({ + "name": name, "lat": lat, "lon": lon + }) + + +def search_walmart_locations_google(query: str = "Walmart Supercenter Indianapolis"): + """ + Placeholder for Google Maps Places API integration. + To use, set GOOGLE_MAPS_API_KEY environment variable. + + In production, this would query the Google Maps Places API + to find Walmart Supercenter locations dynamically. + """ + import os + api_key = os.environ.get("GOOGLE_MAPS_API_KEY") + if not api_key: + print("GOOGLE_MAPS_API_KEY not set. Using hardcoded locations.") + return get_walmart_supercenters() + + encoded_query = urllib.parse.quote(query) + url = ( + f"https://maps.googleapis.com/maps/api/place/textsearch/json" + f"?query={encoded_query}&key={api_key}" + ) + try: + with urllib.request.urlopen(url) as response: + data = json.loads(response.read().decode()) + results = [] + for place in data.get("results", []): + loc = place["geometry"]["location"] + results.append({ + "name": place.get("name", "Unknown"), + "lat": loc["lat"], + "lon": loc["lng"], + }) + return results + except Exception as e: + print(f"Google Maps API error: {e}. Using hardcoded locations.") + return get_walmart_supercenters()