From 16aa04d87f96ad9597014bbdbd55545cb6f61400 Mon Sep 17 00:00:00 2001 From: Chamindu24 Date: Wed, 30 Apr 2025 09:05:58 +0530 Subject: [PATCH 1/4] Implement route optimization API with Flask and OR-Tools; add frontend for route visualization and testing --- requirements.txt | 3 + route_optimizer/app.py | 65 ++++++++ route_optimizer/frontend/index.html | 132 +++++++++++++++++ route_optimizer/optimizer.py | 122 +++++++++++++++ route_optimizer/test.py | 220 ++++++++++++++++++++++++++++ 5 files changed, 542 insertions(+) create mode 100644 route_optimizer/app.py create mode 100644 route_optimizer/frontend/index.html create mode 100644 route_optimizer/optimizer.py create mode 100644 route_optimizer/test.py diff --git a/requirements.txt b/requirements.txt index 9087f19..7f72690 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,3 +20,6 @@ six==1.17.0 sqlparse==0.5.3 tzdata==2025.2 uritemplate==4.1.1 +flask==2.0.3 +requests==2.28.1 +werkzeug==2.0.3 diff --git a/route_optimizer/app.py b/route_optimizer/app.py new file mode 100644 index 0000000..0d31b40 --- /dev/null +++ b/route_optimizer/app.py @@ -0,0 +1,65 @@ +from flask import Flask, request, jsonify +from flask_cors import CORS +from optimizer import RouteOptimizer + +app = Flask(__name__) +CORS(app) # Enable CORS for all routes + +@app.route('/optimize', methods=['POST']) +def optimize_route(): + try: + data = request.get_json() + if not data: + return jsonify({'error': 'No input data provided'}), 400 + if 'locations' not in data: + return jsonify({'error': 'Missing required field: locations'}), 400 + if 'vehicle_capacities' not in data: + return jsonify({'error': 'Missing required field: vehicle_capacities'}), 400 + + locations = data['locations'] + vehicle_capacities = data['vehicle_capacities'] + + # Validate input formats + if not isinstance(locations, list) or not locations: + return jsonify({'error': 'Locations must be a non-empty list'}), 400 + if not isinstance(vehicle_capacities, list) or not vehicle_capacities: + return jsonify({'error': 'Vehicle capacities must be a non-empty list'}), 400 + + # Validate location fields + for loc in locations: + if not all(key in loc for key in ['id', 'coordinates', 'load']): + return jsonify({'error': f'Missing required fields in location: {loc.get("id", "unknown")}'}), 400 + if not isinstance(loc['coordinates'], (list, tuple)) or len(loc['coordinates']) != 2: + return jsonify({'error': f'Invalid coordinates for location: {loc.get("id", "unknown")}'}), 400 + if not all(isinstance(coord, (int, float)) for coord in loc['coordinates']): + return jsonify({'error': f'Coordinates must be numbers for location: {loc.get("id", "unknown")}'}), 400 + if not isinstance(loc['load'], (int, float)): + return jsonify({'error': f'Load must be a number for location: {loc.get("id", "unknown")}'}), 400 + + # Validate vehicle capacities + if not all(isinstance(cap, (int, float)) and cap > 0 for cap in vehicle_capacities): + return jsonify({'error': 'Vehicle capacities must be positive numbers'}), 400 + + optimizer = RouteOptimizer(locations, vehicle_capacities) + result = optimizer.solve() + + if not result: + return jsonify({'error': 'No solution found'}), 400 + + # Add coordinates to each route + location_map = {loc['id']: loc['coordinates'] for loc in locations} + for route in result: + try: + route['coordinates'] = [location_map[loc_id] for loc_id in route['route']] + except KeyError as e: + return jsonify({'error': f'Missing location ID: {e}'}), 400 + + return jsonify({'routes': result}), 200 + + except ValueError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + return jsonify({'error': f'Internal server error: {str(e)}'}), 500 + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000) \ No newline at end of file diff --git a/route_optimizer/frontend/index.html b/route_optimizer/frontend/index.html new file mode 100644 index 0000000..a140f28 --- /dev/null +++ b/route_optimizer/frontend/index.html @@ -0,0 +1,132 @@ + + + + + + Delivery Route Visualization + + + + +
+ + + + + + \ No newline at end of file diff --git a/route_optimizer/optimizer.py b/route_optimizer/optimizer.py new file mode 100644 index 0000000..c307387 --- /dev/null +++ b/route_optimizer/optimizer.py @@ -0,0 +1,122 @@ + +from ortools.constraint_solver import routing_enums_pb2 +from ortools.constraint_solver import pywrapcp +import math + +class RouteOptimizer: + def __init__(self, locations, vehicle_capacities): + self.locations = locations + self.vehicle_capacities = vehicle_capacities + self.depot_index = 0 + #print("RouteOptimizer initialized with locations:", [loc['id'] for loc in locations]) + + def validate_inputs(self): + if not self.locations: + raise ValueError("No delivery locations provided") + if self.locations[0]['load'] != 0: + raise ValueError("Depot must have 0 load") + if len(self.vehicle_capacities) < 1: + raise ValueError("At least one vehicle required") + + def haversine_distance(self, lat1, lon1, lat2, lon2): + R = 6371 # Earth's radius in kilometers + lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2]) + + dlat = lat2 - lat1 + dlon = lon2 - lon1 + a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2 + c = 2 * math.asin(math.sqrt(a)) + distance = R * c * 1000 # Convert to meters + rounded_distance = round(distance) + return rounded_distance + + def create_distance_matrix(self): + matrix = [] + for loc1 in self.locations: + row = [] + lat1, lon1 = loc1['coordinates'] + for loc2 in self.locations: + lat2, lon2 = loc2['coordinates'] + distance = self.haversine_distance(lat1, lon1, lat2, lon2) + row.append(distance) + matrix.append(row) + print("Distance Matrix:", matrix) + return matrix + + def solve(self): + self.validate_inputs() + data = { + 'distance_matrix': self.create_distance_matrix(), + 'loads': [loc['load'] for loc in self.locations], + 'vehicle_capacities': self.vehicle_capacities, + 'num_vehicles': len(self.vehicle_capacities), + 'depot': self.depot_index + } + + manager = pywrapcp.RoutingIndexManager( + len(data['distance_matrix']), + data['num_vehicles'], + data['depot'] + ) + routing = pywrapcp.RoutingModel(manager) + + def distance_callback(from_index, to_index): + from_node = manager.IndexToNode(from_index) + to_node = manager.IndexToNode(to_index) + distance = int(data['distance_matrix'][from_node][to_node]) + return distance + + transit_callback_idx = routing.RegisterTransitCallback(distance_callback) + routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_idx) + + def load_callback(from_index): + return data['loads'][manager.IndexToNode(from_index)] + + load_callback_idx = routing.RegisterUnaryTransitCallback(load_callback) + routing.AddDimensionWithVehicleCapacity( + load_callback_idx, + 0, # null capacity slack + data['vehicle_capacities'], + True, + 'Capacity' + ) + + search_params = pywrapcp.DefaultRoutingSearchParameters() + search_params.first_solution_strategy = ( + routing_enums_pb2.FirstSolutionStrategy.GLOBAL_CHEAPEST_ARC + ) + search_params.time_limit.FromSeconds(10) + print("Solving with OR-Tools...") + solution = routing.SolveWithParameters(search_params) + return self.format_solution(data, manager, routing, solution) + + def format_solution(self, data, manager, routing, solution): + if not solution: + return None + + routes = [] + for vehicle_id in range(data['num_vehicles']): + index = routing.Start(vehicle_id) + route = [] + route_distance = 0 + route_load = 0 + + while not routing.IsEnd(index): + node = manager.IndexToNode(index) + route.append(self.locations[node]['id']) + previous_index = index + index = solution.Value(routing.NextVar(index)) + next_node = manager.IndexToNode(index) + distance = data['distance_matrix'][node][next_node] + route_distance += distance + route_load += data['loads'][node] + + route.append(self.locations[self.depot_index]['id']) + routes.append({ + 'vehicle_id': vehicle_id, + 'route': route, + 'distance': route_distance, + 'load': route_load + }) + + return routes \ No newline at end of file diff --git a/route_optimizer/test.py b/route_optimizer/test.py new file mode 100644 index 0000000..811a7aa --- /dev/null +++ b/route_optimizer/test.py @@ -0,0 +1,220 @@ +import pytest +import requests +from optimizer import RouteOptimizer +from app import app +import math + +# Pytest fixtures for reusable test data +@pytest.fixture +def simple_locations(): + """Simple test locations with depot and two points""" + return [ + {'id': 'depot', 'coordinates': (0, 0), 'load': 0}, + {'id': 'A', 'coordinates': (0.018, 0.018), 'load': 1}, + {'id': 'B', 'coordinates': (0.027, 0.027), 'load': 2} + ] + +@pytest.fixture +def sri_lanka_locations(): + """Real-world Sri Lanka locations""" + return [ + {'id': 'Warehouse_Colombo', 'coordinates': [6.9271, 79.8612], 'load': 0}, + {'id': 'Restaurant_Kandy', 'coordinates': [7.2906, 80.6337], 'load': 3}, + {'id': 'Cafe_Galle', 'coordinates': [6.0535, 80.2210], 'load': 2}, + {'id': 'Shop_Negombo', 'coordinates': [7.2088, 79.8380], 'load': 1}, + {'id': 'Hotel_NuwaraEliya', 'coordinates': [6.9497, 80.7891], 'load': 4}, + {'id': 'Market_Anuradhapura', 'coordinates': [8.3114, 80.4037], 'load': 2}, + {'id': 'Office_Jaffna', 'coordinates': [9.6615, 80.0255], 'load': 3} + ] + +@pytest.fixture +def vehicle_capacities(): + """Standard vehicle capacities""" + return [5, 6, 4] + +@pytest.fixture +def flask_client(): + """Flask test client""" + app.config['TESTING'] = True + with app.test_client() as client: + yield client + +# RouteOptimizer tests +def test_basic_optimization(simple_locations, vehicle_capacities): + """Test basic route optimization with simple locations""" + optimizer = RouteOptimizer(simple_locations, [3]) + result = optimizer.solve() + + assert result is not None, "No solution found" + assert len(result) == 1, "Expected one route" + assert result[0]['route'][0] == 'depot', "Route must start at depot" + assert result[0]['route'][-1] == 'depot', "Route must end at depot" + assert set(result[0]['route'][1:-1]) == {'A', 'B'}, "Route must visit A and B" + assert result[0]['load'] == 3, f"Expected load 3, got {result[0]['load']}" + assert result[0]['distance'] > 0, "Distance must be non-zero" + +def test_sri_lanka_optimization(sri_lanka_locations, vehicle_capacities): + """Test optimization with Sri Lanka locations""" + optimizer = RouteOptimizer(sri_lanka_locations, vehicle_capacities) + result = optimizer.solve() + + assert result is not None, "No solution found" + assert len(result) <= len(vehicle_capacities), "Too many routes" + + # Check all non-depot locations are visited exactly once + all_locations = set(loc['id'] for loc in sri_lanka_locations[1:]) + visited_locations = set() + for route in result: + assert route['route'][0] == 'Warehouse_Colombo', f"Route {route['vehicle_id']} must start at depot" + assert route['route'][-1] == 'Warehouse_Colombo', f"Route {route['vehicle_id']} must end at depot" + assert route['load'] <= vehicle_capacities[route['vehicle_id']], f"Route {route['vehicle_id']} exceeds capacity" + assert route['distance'] >= 10000, f"Route {route['vehicle_id']} distance {route['distance']} too small" + visited_locations.update(route['route'][1:-1]) + + assert visited_locations == all_locations, f"Missing locations: {all_locations - visited_locations}" + +def test_invalid_depot_load(simple_locations): + """Test depot with non-zero load""" + bad_locations = simple_locations.copy() + bad_locations[0]['load'] = 1 + with pytest.raises(ValueError, match="Depot must have 0 load"): + RouteOptimizer(bad_locations, [3]).solve() + +def test_empty_locations(): + """Test empty locations list""" + with pytest.raises(ValueError, match="No delivery locations provided"): + RouteOptimizer([], [3]).solve() + +def test_no_vehicles(simple_locations): + """Test no vehicles provided""" + with pytest.raises(ValueError, match="At least one vehicle required"): + RouteOptimizer(simple_locations, []).solve() + +def test_haversine_distance(): + """Test Haversine distance calculation""" + optimizer = RouteOptimizer([], [3]) + distance = optimizer.haversine_distance(6.9271, 79.8612, 7.2906, 80.6337) # Colombo to Kandy + assert 93000 < distance < 95000, f"Expected ~94.3km, got {distance}m" + +def test_distance_matrix(simple_locations): + """Test distance matrix creation""" + optimizer = RouteOptimizer(simple_locations, [3]) + matrix = optimizer.create_distance_matrix() + assert len(matrix) == 3, "Matrix should have 3 rows" + assert len(matrix[0]) == 3, "Matrix should have 3 columns" + assert matrix[0][0] == 0, "Depot to depot distance should be 0" + assert matrix[1][2] == matrix[2][1], "Matrix should be symmetric" + assert matrix[1][2] > 0, "Distance between A and B should be non-zero" + +def test_no_solution(simple_locations): + """Test scenario with no feasible solution (insufficient capacity)""" + optimizer = RouteOptimizer(simple_locations, [1]) # Capacity too low + result = optimizer.solve() + assert result is None, "Expected no solution due to insufficient capacity" + +# Flask API tests +def test_api_success_simple(flask_client, simple_locations): + """Test API with simple valid input""" + response = flask_client.post( + '/optimize', + json={ + 'locations': simple_locations, + 'vehicle_capacities': [3] + } + ) + assert response.status_code == 200, f"Expected 200, got {response.status_code}" + data = response.get_json() + assert 'routes' in data, "Response must include routes" + assert len(data['routes']) == 1, "Expected one route" + assert 'coordinates' in data['routes'][0], "Route must include coordinates" + assert data['routes'][0]['distance'] > 0, "Distance must be non-zero" + assert data['routes'][0]['load'] == 3, "Expected load 3" + assert len(data['routes'][0]['coordinates']) == len(data['routes'][0]['route']), "Coordinates must match route length" + +def test_api_success_sri_lanka(flask_client, sri_lanka_locations, vehicle_capacities): + """Test API with Sri Lanka locations""" + response = flask_client.post( + '/optimize', + json={ + 'locations': sri_lanka_locations, + 'vehicle_capacities': vehicle_capacities + } + ) + assert response.status_code == 200, f"Expected 200, got {response.status_code}" + data = response.get_json() + assert 'routes' in data, "Response must include routes" + + all_locations = set(loc['id'] for loc in sri_lanka_locations[1:]) + visited_locations = set() + for route in data['routes']: + assert 'coordinates' in route, f"Route {route['vehicle_id']} missing coordinates" + assert route['distance'] >= 10000, f"Route {route['vehicle_id']} distance {route['distance']} too small" + assert len(route['coordinates']) == len(route['route']), "Coordinates must match route length" + visited_locations.update(route['route'][1:-1]) + + assert visited_locations == all_locations, f"Missing locations: {all_locations - visited_locations}" + +def test_api_missing_locations(flask_client): + """Test API with missing locations""" + response = flask_client.post( + '/optimize', + json={'vehicle_capacities': [3]} + ) + assert response.status_code == 400, f"Expected 400, got {response.status_code}" + data = response.get_json() + assert 'error' in data, "Response must include error" + assert 'Missing required field' in data['error'], "Expected missing field error" + +def test_api_missing_capacities(flask_client, simple_locations): + """Test API with missing vehicle capacities""" + response = flask_client.post( + '/optimize', + json={'locations': simple_locations} + ) + assert response.status_code == 400, f"Expected 400, got {response.status_code}" + data = response.get_json() + assert 'error' in data, "Response must include error" + assert 'Missing required field' in data['error'], "Expected missing field error" + +def test_api_invalid_coordinates(flask_client): + """Test API with invalid coordinates""" + bad_locations = [ + {'id': 'depot', 'coordinates': [0, 0], 'load': 0}, + {'id': 'A', 'coordinates': 'invalid', 'load': 1} + ] + response = flask_client.post( + '/optimize', + json={'locations': bad_locations, 'vehicle_capacities': [3]} + ) + assert response.status_code == 400, f"Expected 400, got {response.status_code}" + data = response.get_json() + assert 'error' in data, "Response must include error" + +def test_api_no_solution(flask_client, simple_locations): + """Test API with no feasible solution""" + response = flask_client.post( + '/optimize', + json={'locations': simple_locations, 'vehicle_capacities': [1]} + ) + assert response.status_code == 400, f"Expected 400, got {response.status_code}" + data = response.get_json() + assert 'error' in data, "Response must include error" + assert 'No solution found' in data['error'], "Expected no solution error" + +def test_api_internal_error(flask_client, simple_locations, monkeypatch): + """Test API with internal server error (mocked failure)""" + def mock_solve(*args, **kwargs): + raise Exception("Mocked internal error") + + monkeypatch.setattr(RouteOptimizer, 'solve', mock_solve) + response = flask_client.post( + '/optimize', + json={'locations': simple_locations, 'vehicle_capacities': [3]} + ) + assert response.status_code == 500, f"Expected 500, got {response.status_code}" + data = response.get_json() + assert 'error' in data, "Response must include error" + assert 'Internal server error' in data['error'], "Expected internal error" + +if __name__ == "__main__": + pytest.main(["-v", __file__]) \ No newline at end of file From 8e7740cafb55f2a190795459cce4d21e990a16a9 Mon Sep 17 00:00:00 2001 From: Chamindu24 Date: Wed, 30 Apr 2025 09:10:44 +0530 Subject: [PATCH 2/4] Remove debug print statement from solve method in RouteOptimizer class --- route_optimizer/optimizer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/route_optimizer/optimizer.py b/route_optimizer/optimizer.py index c307387..a6d5101 100644 --- a/route_optimizer/optimizer.py +++ b/route_optimizer/optimizer.py @@ -86,7 +86,6 @@ def load_callback(from_index): routing_enums_pb2.FirstSolutionStrategy.GLOBAL_CHEAPEST_ARC ) search_params.time_limit.FromSeconds(10) - print("Solving with OR-Tools...") solution = routing.SolveWithParameters(search_params) return self.format_solution(data, manager, routing, solution) From f9742117d42726ef5578ef4abf44f3f81b8d2242 Mon Sep 17 00:00:00 2001 From: Chamindu24 Date: Thu, 1 May 2025 20:57:46 +0530 Subject: [PATCH 3/4] Add route optimization functionality and integrate with Django - Implement Assignment model with optimized_distance field - Create AssignmentViewSet for handling route optimization requests - Integrate RouteOptimizer for calculating optimized routes and distances - Add logging for debugging and error handling - Register route_optimizer app in Django settings - Remove Flask-based route optimization API and frontend - Add tests for RouteOptimizer functionality --- assignment/models.py | 1 + assignment/views.py | 84 +++++++++- logistics_core/settings.py | 1 + route_optimizer/__init__.py | 0 route_optimizer/admin.py | 3 + route_optimizer/app.py | 65 -------- route_optimizer/apps.py | 6 + route_optimizer/frontend/index.html | 132 --------------- route_optimizer/migrations/__init__.py | 0 route_optimizer/models.py | 3 + route_optimizer/optimizer.py | 16 +- route_optimizer/test.py | 220 ------------------------- route_optimizer/tests.py | 99 +++++++++++ route_optimizer/views.py | 3 + 14 files changed, 200 insertions(+), 433 deletions(-) create mode 100644 route_optimizer/__init__.py create mode 100644 route_optimizer/admin.py delete mode 100644 route_optimizer/app.py create mode 100644 route_optimizer/apps.py delete mode 100644 route_optimizer/frontend/index.html create mode 100644 route_optimizer/migrations/__init__.py create mode 100644 route_optimizer/models.py delete mode 100644 route_optimizer/test.py create mode 100644 route_optimizer/tests.py create mode 100644 route_optimizer/views.py diff --git a/assignment/models.py b/assignment/models.py index 0f84e86..b28df48 100644 --- a/assignment/models.py +++ b/assignment/models.py @@ -6,6 +6,7 @@ class Assignment(models.Model): created_at = models.DateTimeField(auto_now_add=True) delivery_locations = models.JSONField() # List of [longitude, latitude] total_load = models.PositiveIntegerField() + optimized_distance = models.PositiveIntegerField(null=True, blank=True) # Store distance in meters def __str__(self): return f"Assignment #{self.id} to {self.vehicle.vehicle_id}" diff --git a/assignment/views.py b/assignment/views.py index 2680f55..322e371 100644 --- a/assignment/views.py +++ b/assignment/views.py @@ -3,28 +3,106 @@ from .models import Assignment from .serializers import AssignmentSerializer from fleet.models import Vehicle +from route_optimizer.optimizer import RouteOptimizer +from django.conf import settings +import logging + +# Set up logging +logger = logging.getLogger(__name__) class AssignmentViewSet(viewsets.ModelViewSet): queryset = Assignment.objects.all() serializer_class = AssignmentSerializer def create(self, request, *args, **kwargs): + # Log the raw payload for debugging + logger.debug("Received payload: %s", request.data) + + # Check for deliveries deliveries = request.data.get("deliveries") if not deliveries: + logger.error("No deliveries provided") return Response({"error": "Deliveries required"}, status=400) + # Validate deliveries format + if not isinstance(deliveries, list): + logger.error("Deliveries must be a list") + return Response({"error": "Deliveries must be a list"}, status=400) + + for delivery in deliveries: + location = delivery.get("location") + load = delivery.get("load") + if not isinstance(location, list) or len(location) != 2: + logger.error("Invalid location format: %s", delivery) + return Response({"error": f"Invalid location format for delivery: {delivery}"}, status=400) + if not all(isinstance(coord, (int, float)) for coord in location): + logger.error("Location coordinates must be numbers: %s", delivery) + return Response({"error": f"Location coordinates must be numbers: {delivery}"}, status=400) + if not isinstance(load, (int, float)) or load <= 0: + logger.error("Invalid load: %s", delivery) + return Response({"error": f"Invalid load for delivery: {delivery}"}, status=400) + + # Calculate total load total_load = sum(d.get("load", 0) for d in deliveries) + logger.debug("Total load: %s", total_load) + + # Find an available vehicle vehicle = Vehicle.objects.filter(status="available", capacity__gte=total_load).first() if not vehicle: + logger.error("No available vehicle for load: %s", total_load) return Response({"error": "No available vehicle for the load"}, status=400) + logger.debug("Selected vehicle: %s", vehicle.vehicle_id) + + # Prepare data for RouteOptimizer + depot = getattr(settings, 'DEPOT_COORDINATES', [77.58, 12.96]) + locations = [ + {'id': 'depot', 'coordinates': depot, 'load': 0} + ] + [ + { + 'id': f'loc_{i}', + 'coordinates': d.get("location"), + 'load': d.get("load") + } + for i, d in enumerate(deliveries) + ] + logger.debug("RouteOptimizer locations: %s", locations) + # Run RouteOptimizer + try: + optimizer = RouteOptimizer(locations, [vehicle.capacity]) + routes = optimizer.solve() + if not routes: + logger.error("No feasible route found for locations: %s", locations) + return Response({"error": "No feasible route found"}, status=400) + + # Extract the optimized route + optimized_route = routes[0]['route'] + optimized_distance = routes[0]['distance'] + logger.debug("Optimized route: %s, distance: %s", optimized_route, optimized_distance) + + # Map optimized route to coordinates, excluding depot + location_map = {loc['id']: loc['coordinates'] for loc in locations} + delivery_locations = [location_map[loc_id] for loc_id in optimized_route[1:-1]] + + except ValueError as e: + logger.error("RouteOptimizer validation error: %s", str(e)) + return Response({"error": f"Invalid input for route optimization: {str(e)}"}, status=400) + except Exception as e: + logger.error("RouteOptimizer error: %s", str(e)) + return Response({"error": f"Route optimization failed: {str(e)}"}, status=500) + + # Mark vehicle as assigned vehicle.status = "assigned" vehicle.save() + # Create Assignment assignment = Assignment.objects.create( vehicle=vehicle, - delivery_locations=[d["location"] for d in deliveries], - total_load=total_load + delivery_locations=delivery_locations, + total_load=total_load, + optimized_distance=optimized_distance ) + logger.debug("Created assignment: %s", assignment) + serializer = self.get_serializer(assignment) - return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.data, status=status.HTTP_201_CREATED) \ No newline at end of file diff --git a/logistics_core/settings.py b/logistics_core/settings.py index 38fb8e7..61dfe65 100644 --- a/logistics_core/settings.py +++ b/logistics_core/settings.py @@ -42,6 +42,7 @@ 'assignment', 'monitoring', 'drf_yasg', + 'route_optimizer', ] MIDDLEWARE = [ diff --git a/route_optimizer/__init__.py b/route_optimizer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/route_optimizer/admin.py b/route_optimizer/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/route_optimizer/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/route_optimizer/app.py b/route_optimizer/app.py deleted file mode 100644 index 0d31b40..0000000 --- a/route_optimizer/app.py +++ /dev/null @@ -1,65 +0,0 @@ -from flask import Flask, request, jsonify -from flask_cors import CORS -from optimizer import RouteOptimizer - -app = Flask(__name__) -CORS(app) # Enable CORS for all routes - -@app.route('/optimize', methods=['POST']) -def optimize_route(): - try: - data = request.get_json() - if not data: - return jsonify({'error': 'No input data provided'}), 400 - if 'locations' not in data: - return jsonify({'error': 'Missing required field: locations'}), 400 - if 'vehicle_capacities' not in data: - return jsonify({'error': 'Missing required field: vehicle_capacities'}), 400 - - locations = data['locations'] - vehicle_capacities = data['vehicle_capacities'] - - # Validate input formats - if not isinstance(locations, list) or not locations: - return jsonify({'error': 'Locations must be a non-empty list'}), 400 - if not isinstance(vehicle_capacities, list) or not vehicle_capacities: - return jsonify({'error': 'Vehicle capacities must be a non-empty list'}), 400 - - # Validate location fields - for loc in locations: - if not all(key in loc for key in ['id', 'coordinates', 'load']): - return jsonify({'error': f'Missing required fields in location: {loc.get("id", "unknown")}'}), 400 - if not isinstance(loc['coordinates'], (list, tuple)) or len(loc['coordinates']) != 2: - return jsonify({'error': f'Invalid coordinates for location: {loc.get("id", "unknown")}'}), 400 - if not all(isinstance(coord, (int, float)) for coord in loc['coordinates']): - return jsonify({'error': f'Coordinates must be numbers for location: {loc.get("id", "unknown")}'}), 400 - if not isinstance(loc['load'], (int, float)): - return jsonify({'error': f'Load must be a number for location: {loc.get("id", "unknown")}'}), 400 - - # Validate vehicle capacities - if not all(isinstance(cap, (int, float)) and cap > 0 for cap in vehicle_capacities): - return jsonify({'error': 'Vehicle capacities must be positive numbers'}), 400 - - optimizer = RouteOptimizer(locations, vehicle_capacities) - result = optimizer.solve() - - if not result: - return jsonify({'error': 'No solution found'}), 400 - - # Add coordinates to each route - location_map = {loc['id']: loc['coordinates'] for loc in locations} - for route in result: - try: - route['coordinates'] = [location_map[loc_id] for loc_id in route['route']] - except KeyError as e: - return jsonify({'error': f'Missing location ID: {e}'}), 400 - - return jsonify({'routes': result}), 200 - - except ValueError as e: - return jsonify({'error': str(e)}), 400 - except Exception as e: - return jsonify({'error': f'Internal server error: {str(e)}'}), 500 - -if __name__ == '__main__': - app.run(host='0.0.0.0', port=5000) \ No newline at end of file diff --git a/route_optimizer/apps.py b/route_optimizer/apps.py new file mode 100644 index 0000000..2aa0a9a --- /dev/null +++ b/route_optimizer/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RouteOptimizerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'route_optimizer' diff --git a/route_optimizer/frontend/index.html b/route_optimizer/frontend/index.html deleted file mode 100644 index a140f28..0000000 --- a/route_optimizer/frontend/index.html +++ /dev/null @@ -1,132 +0,0 @@ - - - - - - Delivery Route Visualization - - - - -
- - - - - - \ No newline at end of file diff --git a/route_optimizer/migrations/__init__.py b/route_optimizer/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/route_optimizer/models.py b/route_optimizer/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/route_optimizer/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/route_optimizer/optimizer.py b/route_optimizer/optimizer.py index a6d5101..53c6e48 100644 --- a/route_optimizer/optimizer.py +++ b/route_optimizer/optimizer.py @@ -1,4 +1,3 @@ - from ortools.constraint_solver import routing_enums_pb2 from ortools.constraint_solver import pywrapcp import math @@ -8,7 +7,6 @@ def __init__(self, locations, vehicle_capacities): self.locations = locations self.vehicle_capacities = vehicle_capacities self.depot_index = 0 - #print("RouteOptimizer initialized with locations:", [loc['id'] for loc in locations]) def validate_inputs(self): if not self.locations: @@ -21,14 +19,12 @@ def validate_inputs(self): def haversine_distance(self, lat1, lon1, lat2, lon2): R = 6371 # Earth's radius in kilometers lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2]) - dlat = lat2 - lat1 dlon = lon2 - lon1 a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2 c = 2 * math.asin(math.sqrt(a)) distance = R * c * 1000 # Convert to meters - rounded_distance = round(distance) - return rounded_distance + return round(distance) def create_distance_matrix(self): matrix = [] @@ -40,7 +36,6 @@ def create_distance_matrix(self): distance = self.haversine_distance(lat1, lon1, lat2, lon2) row.append(distance) matrix.append(row) - print("Distance Matrix:", matrix) return matrix def solve(self): @@ -63,8 +58,7 @@ def solve(self): def distance_callback(from_index, to_index): from_node = manager.IndexToNode(from_index) to_node = manager.IndexToNode(to_index) - distance = int(data['distance_matrix'][from_node][to_node]) - return distance + return int(data['distance_matrix'][from_node][to_node]) transit_callback_idx = routing.RegisterTransitCallback(distance_callback) routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_idx) @@ -99,17 +93,14 @@ def format_solution(self, data, manager, routing, solution): route = [] route_distance = 0 route_load = 0 - while not routing.IsEnd(index): node = manager.IndexToNode(index) route.append(self.locations[node]['id']) previous_index = index index = solution.Value(routing.NextVar(index)) next_node = manager.IndexToNode(index) - distance = data['distance_matrix'][node][next_node] - route_distance += distance + route_distance += data['distance_matrix'][node][next_node] route_load += data['loads'][node] - route.append(self.locations[self.depot_index]['id']) routes.append({ 'vehicle_id': vehicle_id, @@ -117,5 +108,4 @@ def format_solution(self, data, manager, routing, solution): 'distance': route_distance, 'load': route_load }) - return routes \ No newline at end of file diff --git a/route_optimizer/test.py b/route_optimizer/test.py deleted file mode 100644 index 811a7aa..0000000 --- a/route_optimizer/test.py +++ /dev/null @@ -1,220 +0,0 @@ -import pytest -import requests -from optimizer import RouteOptimizer -from app import app -import math - -# Pytest fixtures for reusable test data -@pytest.fixture -def simple_locations(): - """Simple test locations with depot and two points""" - return [ - {'id': 'depot', 'coordinates': (0, 0), 'load': 0}, - {'id': 'A', 'coordinates': (0.018, 0.018), 'load': 1}, - {'id': 'B', 'coordinates': (0.027, 0.027), 'load': 2} - ] - -@pytest.fixture -def sri_lanka_locations(): - """Real-world Sri Lanka locations""" - return [ - {'id': 'Warehouse_Colombo', 'coordinates': [6.9271, 79.8612], 'load': 0}, - {'id': 'Restaurant_Kandy', 'coordinates': [7.2906, 80.6337], 'load': 3}, - {'id': 'Cafe_Galle', 'coordinates': [6.0535, 80.2210], 'load': 2}, - {'id': 'Shop_Negombo', 'coordinates': [7.2088, 79.8380], 'load': 1}, - {'id': 'Hotel_NuwaraEliya', 'coordinates': [6.9497, 80.7891], 'load': 4}, - {'id': 'Market_Anuradhapura', 'coordinates': [8.3114, 80.4037], 'load': 2}, - {'id': 'Office_Jaffna', 'coordinates': [9.6615, 80.0255], 'load': 3} - ] - -@pytest.fixture -def vehicle_capacities(): - """Standard vehicle capacities""" - return [5, 6, 4] - -@pytest.fixture -def flask_client(): - """Flask test client""" - app.config['TESTING'] = True - with app.test_client() as client: - yield client - -# RouteOptimizer tests -def test_basic_optimization(simple_locations, vehicle_capacities): - """Test basic route optimization with simple locations""" - optimizer = RouteOptimizer(simple_locations, [3]) - result = optimizer.solve() - - assert result is not None, "No solution found" - assert len(result) == 1, "Expected one route" - assert result[0]['route'][0] == 'depot', "Route must start at depot" - assert result[0]['route'][-1] == 'depot', "Route must end at depot" - assert set(result[0]['route'][1:-1]) == {'A', 'B'}, "Route must visit A and B" - assert result[0]['load'] == 3, f"Expected load 3, got {result[0]['load']}" - assert result[0]['distance'] > 0, "Distance must be non-zero" - -def test_sri_lanka_optimization(sri_lanka_locations, vehicle_capacities): - """Test optimization with Sri Lanka locations""" - optimizer = RouteOptimizer(sri_lanka_locations, vehicle_capacities) - result = optimizer.solve() - - assert result is not None, "No solution found" - assert len(result) <= len(vehicle_capacities), "Too many routes" - - # Check all non-depot locations are visited exactly once - all_locations = set(loc['id'] for loc in sri_lanka_locations[1:]) - visited_locations = set() - for route in result: - assert route['route'][0] == 'Warehouse_Colombo', f"Route {route['vehicle_id']} must start at depot" - assert route['route'][-1] == 'Warehouse_Colombo', f"Route {route['vehicle_id']} must end at depot" - assert route['load'] <= vehicle_capacities[route['vehicle_id']], f"Route {route['vehicle_id']} exceeds capacity" - assert route['distance'] >= 10000, f"Route {route['vehicle_id']} distance {route['distance']} too small" - visited_locations.update(route['route'][1:-1]) - - assert visited_locations == all_locations, f"Missing locations: {all_locations - visited_locations}" - -def test_invalid_depot_load(simple_locations): - """Test depot with non-zero load""" - bad_locations = simple_locations.copy() - bad_locations[0]['load'] = 1 - with pytest.raises(ValueError, match="Depot must have 0 load"): - RouteOptimizer(bad_locations, [3]).solve() - -def test_empty_locations(): - """Test empty locations list""" - with pytest.raises(ValueError, match="No delivery locations provided"): - RouteOptimizer([], [3]).solve() - -def test_no_vehicles(simple_locations): - """Test no vehicles provided""" - with pytest.raises(ValueError, match="At least one vehicle required"): - RouteOptimizer(simple_locations, []).solve() - -def test_haversine_distance(): - """Test Haversine distance calculation""" - optimizer = RouteOptimizer([], [3]) - distance = optimizer.haversine_distance(6.9271, 79.8612, 7.2906, 80.6337) # Colombo to Kandy - assert 93000 < distance < 95000, f"Expected ~94.3km, got {distance}m" - -def test_distance_matrix(simple_locations): - """Test distance matrix creation""" - optimizer = RouteOptimizer(simple_locations, [3]) - matrix = optimizer.create_distance_matrix() - assert len(matrix) == 3, "Matrix should have 3 rows" - assert len(matrix[0]) == 3, "Matrix should have 3 columns" - assert matrix[0][0] == 0, "Depot to depot distance should be 0" - assert matrix[1][2] == matrix[2][1], "Matrix should be symmetric" - assert matrix[1][2] > 0, "Distance between A and B should be non-zero" - -def test_no_solution(simple_locations): - """Test scenario with no feasible solution (insufficient capacity)""" - optimizer = RouteOptimizer(simple_locations, [1]) # Capacity too low - result = optimizer.solve() - assert result is None, "Expected no solution due to insufficient capacity" - -# Flask API tests -def test_api_success_simple(flask_client, simple_locations): - """Test API with simple valid input""" - response = flask_client.post( - '/optimize', - json={ - 'locations': simple_locations, - 'vehicle_capacities': [3] - } - ) - assert response.status_code == 200, f"Expected 200, got {response.status_code}" - data = response.get_json() - assert 'routes' in data, "Response must include routes" - assert len(data['routes']) == 1, "Expected one route" - assert 'coordinates' in data['routes'][0], "Route must include coordinates" - assert data['routes'][0]['distance'] > 0, "Distance must be non-zero" - assert data['routes'][0]['load'] == 3, "Expected load 3" - assert len(data['routes'][0]['coordinates']) == len(data['routes'][0]['route']), "Coordinates must match route length" - -def test_api_success_sri_lanka(flask_client, sri_lanka_locations, vehicle_capacities): - """Test API with Sri Lanka locations""" - response = flask_client.post( - '/optimize', - json={ - 'locations': sri_lanka_locations, - 'vehicle_capacities': vehicle_capacities - } - ) - assert response.status_code == 200, f"Expected 200, got {response.status_code}" - data = response.get_json() - assert 'routes' in data, "Response must include routes" - - all_locations = set(loc['id'] for loc in sri_lanka_locations[1:]) - visited_locations = set() - for route in data['routes']: - assert 'coordinates' in route, f"Route {route['vehicle_id']} missing coordinates" - assert route['distance'] >= 10000, f"Route {route['vehicle_id']} distance {route['distance']} too small" - assert len(route['coordinates']) == len(route['route']), "Coordinates must match route length" - visited_locations.update(route['route'][1:-1]) - - assert visited_locations == all_locations, f"Missing locations: {all_locations - visited_locations}" - -def test_api_missing_locations(flask_client): - """Test API with missing locations""" - response = flask_client.post( - '/optimize', - json={'vehicle_capacities': [3]} - ) - assert response.status_code == 400, f"Expected 400, got {response.status_code}" - data = response.get_json() - assert 'error' in data, "Response must include error" - assert 'Missing required field' in data['error'], "Expected missing field error" - -def test_api_missing_capacities(flask_client, simple_locations): - """Test API with missing vehicle capacities""" - response = flask_client.post( - '/optimize', - json={'locations': simple_locations} - ) - assert response.status_code == 400, f"Expected 400, got {response.status_code}" - data = response.get_json() - assert 'error' in data, "Response must include error" - assert 'Missing required field' in data['error'], "Expected missing field error" - -def test_api_invalid_coordinates(flask_client): - """Test API with invalid coordinates""" - bad_locations = [ - {'id': 'depot', 'coordinates': [0, 0], 'load': 0}, - {'id': 'A', 'coordinates': 'invalid', 'load': 1} - ] - response = flask_client.post( - '/optimize', - json={'locations': bad_locations, 'vehicle_capacities': [3]} - ) - assert response.status_code == 400, f"Expected 400, got {response.status_code}" - data = response.get_json() - assert 'error' in data, "Response must include error" - -def test_api_no_solution(flask_client, simple_locations): - """Test API with no feasible solution""" - response = flask_client.post( - '/optimize', - json={'locations': simple_locations, 'vehicle_capacities': [1]} - ) - assert response.status_code == 400, f"Expected 400, got {response.status_code}" - data = response.get_json() - assert 'error' in data, "Response must include error" - assert 'No solution found' in data['error'], "Expected no solution error" - -def test_api_internal_error(flask_client, simple_locations, monkeypatch): - """Test API with internal server error (mocked failure)""" - def mock_solve(*args, **kwargs): - raise Exception("Mocked internal error") - - monkeypatch.setattr(RouteOptimizer, 'solve', mock_solve) - response = flask_client.post( - '/optimize', - json={'locations': simple_locations, 'vehicle_capacities': [3]} - ) - assert response.status_code == 500, f"Expected 500, got {response.status_code}" - data = response.get_json() - assert 'error' in data, "Response must include error" - assert 'Internal server error' in data['error'], "Expected internal error" - -if __name__ == "__main__": - pytest.main(["-v", __file__]) \ No newline at end of file diff --git a/route_optimizer/tests.py b/route_optimizer/tests.py new file mode 100644 index 0000000..9b14ce9 --- /dev/null +++ b/route_optimizer/tests.py @@ -0,0 +1,99 @@ +from django.test import TestCase + +# Create your tests here. +from django.test import TestCase +from route_optimizer.optimizer import RouteOptimizer + +class RouteOptimizerTest(TestCase): + def setUp(self): + """Set up test data for RouteOptimizer tests.""" + self.simple_locations = [ + {'id': 'depot', 'coordinates': [0, 0], 'load': 0}, + {'id': 'A', 'coordinates': [0.018, 0.018], 'load': 1}, + {'id': 'B', 'coordinates': [0.027, 0.027], 'load': 2} + ] + self.vehicle_capacities = [3] + self.sri_lanka_locations = [ + {'id': 'Warehouse_Colombo', 'coordinates': [6.9271, 79.8612], 'load': 0}, + {'id': 'Restaurant_Kandy', 'coordinates': [7.2906, 80.6337], 'load': 3}, + {'id': 'Cafe_Galle', 'coordinates': [6.0535, 80.2210], 'load': 2} + ] + + def test_successful_optimization_simple(self): + """Test route optimization with simple locations.""" + optimizer = RouteOptimizer(self.simple_locations, self.vehicle_capacities) + result = optimizer.solve() + + self.assertIsNotNone(result, "Expected a valid solution") + self.assertEqual(len(result), 1, "Expected one route") + self.assertEqual(result[0]['route'][0], 'depot', "Route must start at depot") + self.assertEqual(result[0]['route'][-1], 'depot', "Route must end at depot") + self.assertEqual(set(result[0]['route'][1:-1]), {'A', 'B'}, "Route must visit A and B") + self.assertEqual(result[0]['load'], 3, "Expected total load of 3") + self.assertGreater(result[0]['distance'], 0, "Distance must be non-zero") + + def test_successful_optimization_sri_lanka(self): + """Test route optimization with Sri Lanka locations.""" + optimizer = RouteOptimizer(self.sri_lanka_locations, [5]) + result = optimizer.solve() + + self.assertIsNotNone(result, "Expected a valid solution") + self.assertEqual(len(result), 1, "Expected one route") + self.assertEqual(result[0]['route'][0], 'Warehouse_Colombo', "Route must start at depot") + self.assertEqual(result[0]['route'][-1], 'Warehouse_Colombo', "Route must end at depot") + self.assertEqual(set(result[0]['route'][1:-1]), {'Restaurant_Kandy', 'Cafe_Galle'}, + "Route must visit Kandy and Galle") + self.assertEqual(result[0]['load'], 5, "Expected total load of 5") + self.assertGreaterEqual(result[0]['distance'], 10000, "Distance must be significant") + + def test_insufficient_vehicle_capacity(self): + """Test optimization with insufficient vehicle capacity.""" + optimizer = RouteOptimizer(self.simple_locations, [1]) # Capacity too low + result = optimizer.solve() + + self.assertIsNone(result, "Expected no solution due to insufficient capacity") + + def test_invalid_depot_load(self): + """Test initialization with non-zero depot load.""" + invalid_locations = self.simple_locations.copy() + invalid_locations[0]['load'] = 1 + optimizer = RouteOptimizer(invalid_locations, self.vehicle_capacities) + + with self.assertRaises(ValueError) as context: + optimizer.solve() + self.assertEqual(str(context.exception), "Depot must have 0 load") + + def test_empty_locations(self): + """Test initialization with empty locations list.""" + optimizer = RouteOptimizer([], self.vehicle_capacities) + + with self.assertRaises(ValueError) as context: + optimizer.solve() + self.assertEqual(str(context.exception), "No delivery locations provided") + + def test_no_vehicles(self): + """Test initialization with no vehicles.""" + optimizer = RouteOptimizer(self.simple_locations, []) + + with self.assertRaises(ValueError) as context: + optimizer.solve() + self.assertEqual(str(context.exception), "At least one vehicle required") + + def test_haversine_distance(self): + """Test Haversine distance calculation.""" + optimizer = RouteOptimizer(self.sri_lanka_locations, self.vehicle_capacities) + distance = optimizer.haversine_distance(6.9271, 79.8612, 7.2906, 80.6337) # Colombo to Kandy + + self.assertGreater(distance, 93000, "Distance should be more than 93km") + self.assertLess(distance, 95000, "Distance should be less than 95km") + + def test_distance_matrix(self): + """Test distance matrix creation.""" + optimizer = RouteOptimizer(self.simple_locations, self.vehicle_capacities) + matrix = optimizer.create_distance_matrix() + + self.assertEqual(len(matrix), 3, "Matrix should have 3 rows") + self.assertEqual(len(matrix[0]), 3, "Matrix should have 3 columns") + self.assertEqual(matrix[0][0], 0, "Depot to depot distance should be 0") + self.assertEqual(matrix[1][2], matrix[2][1], "Matrix should be symmetric") + self.assertGreater(matrix[1][2], 0, "Distance between A and B should be non-zero") \ No newline at end of file diff --git a/route_optimizer/views.py b/route_optimizer/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/route_optimizer/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. From 0dfa3b8303c195e108d2338857386c5c50671229 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Thu, 1 May 2025 21:04:12 +0530 Subject: [PATCH 4/4] Update route_optimizer/tests.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- route_optimizer/tests.py | 1 - 1 file changed, 1 deletion(-) diff --git a/route_optimizer/tests.py b/route_optimizer/tests.py index 9b14ce9..320bd56 100644 --- a/route_optimizer/tests.py +++ b/route_optimizer/tests.py @@ -1,7 +1,6 @@ from django.test import TestCase # Create your tests here. -from django.test import TestCase from route_optimizer.optimizer import RouteOptimizer class RouteOptimizerTest(TestCase):