blackopsrepl commited on
Commit
08e15f1
·
verified ·
1 Parent(s): d02874d

Upload 36 files

Browse files
Files changed (32) hide show
  1. src/vehicle_routing/__init__.py +16 -0
  2. src/vehicle_routing/constraints.py +105 -0
  3. src/vehicle_routing/converters.py +228 -0
  4. src/vehicle_routing/demo_data.py +478 -0
  5. src/vehicle_routing/domain.py +371 -0
  6. src/vehicle_routing/json_serialization.py +85 -0
  7. src/vehicle_routing/rest_api.py +524 -0
  8. src/vehicle_routing/routing.py +622 -0
  9. src/vehicle_routing/score_analysis.py +20 -0
  10. src/vehicle_routing/solver.py +23 -0
  11. static/app.js +1607 -0
  12. static/index.html +473 -0
  13. static/recommended-fit.js +206 -0
  14. static/score-analysis.js +96 -0
  15. static/webjars/solverforge/css/solverforge-webui.css +68 -0
  16. static/webjars/solverforge/img/solverforge-favicon.svg +65 -0
  17. static/webjars/solverforge/img/solverforge-horizontal-white.svg +66 -0
  18. static/webjars/solverforge/img/solverforge-horizontal.svg +65 -0
  19. static/webjars/solverforge/img/solverforge-logo-stacked.svg +73 -0
  20. static/webjars/solverforge/js/solverforge-webui.js +142 -0
  21. static/webjars/timefold/css/timefold-webui.css +60 -0
  22. static/webjars/timefold/img/timefold-favicon.svg +25 -0
  23. static/webjars/timefold/img/timefold-logo-horizontal-negative.svg +1 -0
  24. static/webjars/timefold/img/timefold-logo-horizontal-positive.svg +1 -0
  25. static/webjars/timefold/img/timefold-logo-stacked-positive.svg +1 -0
  26. static/webjars/timefold/js/timefold-webui.js +142 -0
  27. tests/test_constraints.py +187 -0
  28. tests/test_demo_data.py +280 -0
  29. tests/test_feasible.py +54 -0
  30. tests/test_haversine.py +156 -0
  31. tests/test_routing.py +431 -0
  32. tests/test_timeline_fields.py +215 -0
src/vehicle_routing/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uvicorn
2
+
3
+ from .rest_api import app as app
4
+
5
+
6
+ def main():
7
+ config = uvicorn.Config("vehicle_routing:app",
8
+ port=8082,
9
+ log_config="logging.conf",
10
+ use_colors=True)
11
+ server = uvicorn.Server(config)
12
+ server.run()
13
+
14
+
15
+ if __name__ == "__main__":
16
+ main()
src/vehicle_routing/constraints.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver.score import (
2
+ ConstraintFactory,
3
+ HardSoftScore,
4
+ constraint_provider,
5
+ )
6
+
7
+ from .domain import Vehicle, Visit
8
+
9
+ VEHICLE_CAPACITY = "vehicleCapacity"
10
+ MINIMIZE_TRAVEL_TIME = "minimizeTravelTime"
11
+ SERVICE_FINISHED_AFTER_MAX_END_TIME = "serviceFinishedAfterMaxEndTime"
12
+ MAX_ROUTE_DURATION = "maxRouteDuration"
13
+
14
+
15
+ @constraint_provider
16
+ def define_constraints(factory: ConstraintFactory):
17
+ return [
18
+ # Hard constraints
19
+ vehicle_capacity(factory),
20
+ service_finished_after_max_end_time(factory),
21
+ # max_route_duration(factory), # Optional extension - disabled by default
22
+ # Soft constraints
23
+ minimize_travel_time(factory),
24
+ ]
25
+
26
+
27
+ ##############################################
28
+ # Hard constraints
29
+ ##############################################
30
+
31
+
32
+ def vehicle_capacity(factory: ConstraintFactory):
33
+ return (
34
+ factory.for_each(Vehicle)
35
+ .filter(lambda vehicle: vehicle.calculate_total_demand() > vehicle.capacity)
36
+ .penalize(
37
+ HardSoftScore.ONE_HARD,
38
+ lambda vehicle: vehicle.calculate_total_demand() - vehicle.capacity,
39
+ )
40
+ .as_constraint(VEHICLE_CAPACITY)
41
+ )
42
+
43
+
44
+ def service_finished_after_max_end_time(factory: ConstraintFactory):
45
+ return (
46
+ factory.for_each(Visit)
47
+ .filter(lambda visit: visit.is_service_finished_after_max_end_time())
48
+ .penalize(
49
+ HardSoftScore.ONE_HARD,
50
+ lambda visit: visit.service_finished_delay_in_minutes(),
51
+ )
52
+ .as_constraint(SERVICE_FINISHED_AFTER_MAX_END_TIME)
53
+ )
54
+
55
+
56
+ ##############################################
57
+ # Soft constraints
58
+ ##############################################
59
+
60
+
61
+ def minimize_travel_time(factory: ConstraintFactory):
62
+ return (
63
+ factory.for_each(Vehicle)
64
+ .penalize(
65
+ HardSoftScore.ONE_SOFT,
66
+ lambda vehicle: vehicle.calculate_total_driving_time_seconds(),
67
+ )
68
+ .as_constraint(MINIMIZE_TRAVEL_TIME)
69
+ )
70
+
71
+
72
+ ##############################################
73
+ # Optional constraints (disabled by default)
74
+ ##############################################
75
+
76
+
77
+ def max_route_duration(factory: ConstraintFactory):
78
+ """
79
+ Hard constraint: Vehicle routes cannot exceed 8 hours total duration.
80
+
81
+ The limit of 8 hours is chosen based on typical driver shift limits:
82
+ - PHILADELPHIA: 55 visits across 6 vehicles, routes typically 4-6 hours
83
+ - FIRENZE: 77 visits across 6 vehicles, routes can approach 8 hours
84
+
85
+ Note: A limit that's too low may make the problem infeasible.
86
+ Always ensure your constraints are compatible with your data dimensions.
87
+ """
88
+ MAX_DURATION_SECONDS = 8 * 60 * 60 # 8 hours
89
+
90
+ return (
91
+ factory.for_each(Vehicle)
92
+ .filter(lambda vehicle: len(vehicle.visits) > 0)
93
+ .filter(lambda vehicle:
94
+ (vehicle.arrival_time - vehicle.departure_time).total_seconds()
95
+ > MAX_DURATION_SECONDS
96
+ )
97
+ .penalize(
98
+ HardSoftScore.ONE_HARD,
99
+ lambda vehicle: int(
100
+ ((vehicle.arrival_time - vehicle.departure_time).total_seconds()
101
+ - MAX_DURATION_SECONDS) / 60
102
+ ),
103
+ )
104
+ .as_constraint(MAX_ROUTE_DURATION)
105
+ )
src/vehicle_routing/converters.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+ from datetime import datetime, timedelta
3
+ from . import domain
4
+
5
+
6
+ # Conversion functions from domain to API models
7
+ def location_to_model(location: domain.Location) -> List[float]:
8
+ return [location.latitude, location.longitude]
9
+
10
+
11
+ def visit_to_model(visit: domain.Visit) -> domain.VisitModel:
12
+ return domain.VisitModel(
13
+ id=visit.id,
14
+ name=visit.name,
15
+ location=location_to_model(visit.location),
16
+ demand=visit.demand,
17
+ min_start_time=visit.min_start_time.isoformat(),
18
+ max_end_time=visit.max_end_time.isoformat(),
19
+ service_duration=int(visit.service_duration.total_seconds()),
20
+ vehicle=visit.vehicle.id if visit.vehicle else None,
21
+ previous_visit=visit.previous_visit.id if visit.previous_visit else None,
22
+ next_visit=visit.next_visit.id if visit.next_visit else None,
23
+ arrival_time=visit.arrival_time.isoformat() if visit.arrival_time else None,
24
+ start_service_time=visit.start_service_time.isoformat()
25
+ if visit.start_service_time
26
+ else None,
27
+ departure_time=visit.departure_time.isoformat()
28
+ if visit.departure_time
29
+ else None,
30
+ driving_time_seconds_from_previous_standstill=visit.driving_time_seconds_from_previous_standstill,
31
+ )
32
+
33
+
34
+ def vehicle_to_model(vehicle: domain.Vehicle) -> domain.VehicleModel:
35
+ return domain.VehicleModel(
36
+ id=vehicle.id,
37
+ name=vehicle.name,
38
+ capacity=vehicle.capacity,
39
+ home_location=location_to_model(vehicle.home_location),
40
+ departure_time=vehicle.departure_time.isoformat(),
41
+ visits=[visit.id for visit in vehicle.visits],
42
+ total_demand=vehicle.total_demand,
43
+ total_driving_time_seconds=vehicle.total_driving_time_seconds,
44
+ arrival_time=vehicle.arrival_time.isoformat(),
45
+ )
46
+
47
+
48
+ def plan_to_model(plan: domain.VehicleRoutePlan) -> domain.VehicleRoutePlanModel:
49
+ return domain.VehicleRoutePlanModel(
50
+ name=plan.name,
51
+ south_west_corner=location_to_model(plan.south_west_corner),
52
+ north_east_corner=location_to_model(plan.north_east_corner),
53
+ vehicles=[vehicle_to_model(v) for v in plan.vehicles],
54
+ visits=[visit_to_model(v) for v in plan.visits],
55
+ score=str(plan.score) if plan.score else None,
56
+ solver_status=plan.solver_status.name if plan.solver_status else None,
57
+ total_driving_time_seconds=plan.total_driving_time_seconds,
58
+ start_date_time=plan.start_date_time.isoformat()
59
+ if plan.start_date_time
60
+ else None,
61
+ end_date_time=plan.end_date_time.isoformat() if plan.end_date_time else None,
62
+ )
63
+
64
+
65
+ # Conversion functions from API models to domain
66
+ def model_to_location(model: List[float]) -> domain.Location:
67
+ return domain.Location(latitude=model[0], longitude=model[1])
68
+
69
+
70
+ def model_to_visit(
71
+ model: domain.VisitModel, vehicle_lookup: dict, visit_lookup: dict
72
+ ) -> domain.Visit:
73
+ # Handle vehicle reference
74
+ vehicle = None
75
+ if model.vehicle:
76
+ if isinstance(model.vehicle, str):
77
+ vehicle = vehicle_lookup[model.vehicle]
78
+ else:
79
+ # This shouldn't happen in practice, but handle it for completeness
80
+ vehicle = vehicle_lookup[model.vehicle.id]
81
+
82
+ # Handle previous visit reference
83
+ previous_visit = None
84
+ if model.previous_visit:
85
+ if isinstance(model.previous_visit, str):
86
+ previous_visit = visit_lookup[model.previous_visit]
87
+ else:
88
+ previous_visit = visit_lookup[model.previous_visit.id]
89
+
90
+ # Handle next visit reference
91
+ next_visit = None
92
+ if model.next_visit:
93
+ if isinstance(model.next_visit, str):
94
+ next_visit = visit_lookup[model.next_visit]
95
+ else:
96
+ next_visit = visit_lookup[model.next_visit.id]
97
+
98
+ return domain.Visit(
99
+ id=model.id,
100
+ name=model.name,
101
+ location=model_to_location(model.location),
102
+ demand=model.demand,
103
+ min_start_time=datetime.fromisoformat(model.min_start_time),
104
+ max_end_time=datetime.fromisoformat(model.max_end_time),
105
+ service_duration=timedelta(seconds=model.service_duration),
106
+ vehicle=vehicle,
107
+ previous_visit=previous_visit,
108
+ next_visit=next_visit,
109
+ arrival_time=datetime.fromisoformat(model.arrival_time)
110
+ if model.arrival_time
111
+ else None,
112
+ )
113
+
114
+
115
+ def model_to_vehicle(model: domain.VehicleModel, visit_lookup: dict) -> domain.Vehicle:
116
+ # Handle visits references
117
+ visits = []
118
+ for visit_ref in model.visits:
119
+ if isinstance(visit_ref, str):
120
+ visits.append(visit_lookup[visit_ref])
121
+ else:
122
+ visits.append(visit_lookup[visit_ref.id])
123
+
124
+ return domain.Vehicle(
125
+ id=model.id,
126
+ capacity=model.capacity,
127
+ home_location=model_to_location(model.home_location),
128
+ departure_time=datetime.fromisoformat(model.departure_time),
129
+ visits=visits,
130
+ )
131
+
132
+
133
+ def model_to_plan(model: domain.VehicleRoutePlanModel) -> domain.VehicleRoutePlan:
134
+ # Convert basic collections first
135
+ vehicles = []
136
+ visits = []
137
+
138
+ # Convert visits first (they don't depend on vehicles)
139
+ for visit_model in model.visits:
140
+ visit = domain.Visit(
141
+ id=visit_model.id,
142
+ name=visit_model.name,
143
+ location=model_to_location(visit_model.location),
144
+ demand=visit_model.demand,
145
+ min_start_time=datetime.fromisoformat(visit_model.min_start_time),
146
+ max_end_time=datetime.fromisoformat(visit_model.max_end_time),
147
+ service_duration=timedelta(seconds=visit_model.service_duration),
148
+ vehicle=None, # Will be set later
149
+ previous_visit=None, # Will be set later
150
+ next_visit=None, # Will be set later
151
+ arrival_time=datetime.fromisoformat(visit_model.arrival_time)
152
+ if visit_model.arrival_time
153
+ else None,
154
+ )
155
+ visits.append(visit)
156
+
157
+ # Create lookup dictionaries
158
+ visit_lookup = {v.id: v for v in visits}
159
+
160
+ # Convert vehicles
161
+ for vehicle_model in model.vehicles:
162
+ vehicle = domain.Vehicle(
163
+ id=vehicle_model.id,
164
+ name=vehicle_model.name,
165
+ capacity=vehicle_model.capacity,
166
+ home_location=model_to_location(vehicle_model.home_location),
167
+ departure_time=datetime.fromisoformat(vehicle_model.departure_time),
168
+ visits=[],
169
+ )
170
+ vehicles.append(vehicle)
171
+
172
+ # Create vehicle lookup
173
+ vehicle_lookup = {v.id: v for v in vehicles}
174
+
175
+ # Now set up the relationships
176
+ for i, visit_model in enumerate(model.visits):
177
+ visit = visits[i]
178
+
179
+ # Set vehicle reference
180
+ if visit_model.vehicle:
181
+ if isinstance(visit_model.vehicle, str):
182
+ visit.vehicle = vehicle_lookup[visit_model.vehicle]
183
+ else:
184
+ visit.vehicle = vehicle_lookup[visit_model.vehicle.id]
185
+
186
+ # Set previous/next visit references
187
+ if visit_model.previous_visit:
188
+ if isinstance(visit_model.previous_visit, str):
189
+ visit.previous_visit = visit_lookup[visit_model.previous_visit]
190
+ else:
191
+ visit.previous_visit = visit_lookup[visit_model.previous_visit.id]
192
+
193
+ if visit_model.next_visit:
194
+ if isinstance(visit_model.next_visit, str):
195
+ visit.next_visit = visit_lookup[visit_model.next_visit]
196
+ else:
197
+ visit.next_visit = visit_lookup[visit_model.next_visit.id]
198
+
199
+ # Set up vehicle visits lists
200
+ for vehicle_model in model.vehicles:
201
+ vehicle = vehicle_lookup[vehicle_model.id]
202
+ for visit_ref in vehicle_model.visits:
203
+ if isinstance(visit_ref, str):
204
+ vehicle.visits.append(visit_lookup[visit_ref])
205
+ else:
206
+ vehicle.visits.append(visit_lookup[visit_ref.id])
207
+
208
+ # Handle score
209
+ score = None
210
+ if model.score:
211
+ from solverforge_legacy.solver.score import HardSoftScore
212
+
213
+ score = HardSoftScore.parse(model.score)
214
+
215
+ # Handle solver status
216
+ solver_status = domain.SolverStatus.NOT_SOLVING
217
+ if model.solver_status:
218
+ solver_status = domain.SolverStatus[model.solver_status]
219
+
220
+ return domain.VehicleRoutePlan(
221
+ name=model.name,
222
+ south_west_corner=model_to_location(model.south_west_corner),
223
+ north_east_corner=model_to_location(model.north_east_corner),
224
+ vehicles=vehicles,
225
+ visits=visits,
226
+ score=score,
227
+ solver_status=solver_status,
228
+ )
src/vehicle_routing/demo_data.py ADDED
@@ -0,0 +1,478 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Generator, TypeVar, Sequence, Optional
2
+ from datetime import date, datetime, time, timedelta
3
+ from enum import Enum
4
+ from random import Random
5
+ from dataclasses import dataclass, field
6
+
7
+ from .domain import Location, Vehicle, VehicleRoutePlan, Visit
8
+
9
+
10
+ FIRST_NAMES = ("Amy", "Beth", "Carl", "Dan", "Elsa", "Flo", "Gus", "Hugo", "Ivy", "Jay")
11
+ LAST_NAMES = ("Cole", "Fox", "Green", "Jones", "King", "Li", "Poe", "Rye", "Smith", "Watt")
12
+
13
+
14
+ # Real Philadelphia street addresses for demo data
15
+ # These are actual locations on the road network for realistic routing
16
+ PHILADELPHIA_REAL_LOCATIONS = {
17
+ "depots": [
18
+ {"name": "Central Depot - City Hall", "lat": 39.9526, "lng": -75.1652},
19
+ {"name": "South Philly Depot", "lat": 39.9256, "lng": -75.1697},
20
+ {"name": "University City Depot", "lat": 39.9522, "lng": -75.1932},
21
+ {"name": "North Philly Depot", "lat": 39.9907, "lng": -75.1556},
22
+ {"name": "Fishtown Depot", "lat": 39.9712, "lng": -75.1340},
23
+ {"name": "West Philly Depot", "lat": 39.9601, "lng": -75.2175},
24
+ ],
25
+ "visits": [
26
+ # Restaurants (for early morning deliveries)
27
+ {"name": "Reading Terminal Market", "lat": 39.9535, "lng": -75.1589, "type": "RESTAURANT"},
28
+ {"name": "Parc Restaurant", "lat": 39.9493, "lng": -75.1727, "type": "RESTAURANT"},
29
+ {"name": "Zahav", "lat": 39.9430, "lng": -75.1474, "type": "RESTAURANT"},
30
+ {"name": "Vetri Cucina", "lat": 39.9499, "lng": -75.1659, "type": "RESTAURANT"},
31
+ {"name": "Talula's Garden", "lat": 39.9470, "lng": -75.1709, "type": "RESTAURANT"},
32
+ {"name": "Fork", "lat": 39.9493, "lng": -75.1539, "type": "RESTAURANT"},
33
+ {"name": "Morimoto", "lat": 39.9488, "lng": -75.1559, "type": "RESTAURANT"},
34
+ {"name": "Vernick Food & Drink", "lat": 39.9508, "lng": -75.1718, "type": "RESTAURANT"},
35
+ {"name": "Friday Saturday Sunday", "lat": 39.9492, "lng": -75.1715, "type": "RESTAURANT"},
36
+ {"name": "Royal Izakaya", "lat": 39.9410, "lng": -75.1509, "type": "RESTAURANT"},
37
+ {"name": "Laurel", "lat": 39.9392, "lng": -75.1538, "type": "RESTAURANT"},
38
+ {"name": "Marigold Kitchen", "lat": 39.9533, "lng": -75.1920, "type": "RESTAURANT"},
39
+
40
+ # Businesses (for business hours deliveries)
41
+ {"name": "Comcast Center", "lat": 39.9543, "lng": -75.1690, "type": "BUSINESS"},
42
+ {"name": "Liberty Place", "lat": 39.9520, "lng": -75.1685, "type": "BUSINESS"},
43
+ {"name": "BNY Mellon Center", "lat": 39.9505, "lng": -75.1660, "type": "BUSINESS"},
44
+ {"name": "One Liberty Place", "lat": 39.9520, "lng": -75.1685, "type": "BUSINESS"},
45
+ {"name": "Aramark Tower", "lat": 39.9550, "lng": -75.1705, "type": "BUSINESS"},
46
+ {"name": "PSFS Building", "lat": 39.9510, "lng": -75.1618, "type": "BUSINESS"},
47
+ {"name": "Three Logan Square", "lat": 39.9567, "lng": -75.1720, "type": "BUSINESS"},
48
+ {"name": "Two Commerce Square", "lat": 39.9551, "lng": -75.1675, "type": "BUSINESS"},
49
+ {"name": "Penn Medicine", "lat": 39.9495, "lng": -75.1935, "type": "BUSINESS"},
50
+ {"name": "Children's Hospital", "lat": 39.9482, "lng": -75.1950, "type": "BUSINESS"},
51
+ {"name": "Drexel University", "lat": 39.9566, "lng": -75.1899, "type": "BUSINESS"},
52
+ {"name": "Temple University", "lat": 39.9812, "lng": -75.1554, "type": "BUSINESS"},
53
+ {"name": "Jefferson Hospital", "lat": 39.9487, "lng": -75.1577, "type": "BUSINESS"},
54
+ {"name": "Pennsylvania Hospital", "lat": 39.9445, "lng": -75.1545, "type": "BUSINESS"},
55
+ {"name": "FMC Tower", "lat": 39.9499, "lng": -75.1780, "type": "BUSINESS"},
56
+ {"name": "Cira Centre", "lat": 39.9560, "lng": -75.1822, "type": "BUSINESS"},
57
+
58
+ # Residential areas (for evening deliveries)
59
+ {"name": "Rittenhouse Square", "lat": 39.9496, "lng": -75.1718, "type": "RESIDENTIAL"},
60
+ {"name": "Washington Square West", "lat": 39.9468, "lng": -75.1545, "type": "RESIDENTIAL"},
61
+ {"name": "Society Hill", "lat": 39.9425, "lng": -75.1478, "type": "RESIDENTIAL"},
62
+ {"name": "Old City", "lat": 39.9510, "lng": -75.1450, "type": "RESIDENTIAL"},
63
+ {"name": "Northern Liberties", "lat": 39.9650, "lng": -75.1420, "type": "RESIDENTIAL"},
64
+ {"name": "Fishtown", "lat": 39.9712, "lng": -75.1340, "type": "RESIDENTIAL"},
65
+ {"name": "Queen Village", "lat": 39.9380, "lng": -75.1520, "type": "RESIDENTIAL"},
66
+ {"name": "Bella Vista", "lat": 39.9395, "lng": -75.1598, "type": "RESIDENTIAL"},
67
+ {"name": "Graduate Hospital", "lat": 39.9425, "lng": -75.1768, "type": "RESIDENTIAL"},
68
+ {"name": "Fairmount", "lat": 39.9680, "lng": -75.1750, "type": "RESIDENTIAL"},
69
+ {"name": "Spring Garden", "lat": 39.9620, "lng": -75.1620, "type": "RESIDENTIAL"},
70
+ {"name": "Art Museum Area", "lat": 39.9656, "lng": -75.1810, "type": "RESIDENTIAL"},
71
+ {"name": "Brewerytown", "lat": 39.9750, "lng": -75.1850, "type": "RESIDENTIAL"},
72
+ {"name": "East Passyunk", "lat": 39.9310, "lng": -75.1605, "type": "RESIDENTIAL"},
73
+ {"name": "Point Breeze", "lat": 39.9285, "lng": -75.1780, "type": "RESIDENTIAL"},
74
+ {"name": "Pennsport", "lat": 39.9320, "lng": -75.1450, "type": "RESIDENTIAL"},
75
+ {"name": "Powelton Village", "lat": 39.9610, "lng": -75.1950, "type": "RESIDENTIAL"},
76
+ {"name": "Spruce Hill", "lat": 39.9530, "lng": -75.2100, "type": "RESIDENTIAL"},
77
+ {"name": "Cedar Park", "lat": 39.9490, "lng": -75.2200, "type": "RESIDENTIAL"},
78
+ {"name": "Kensington", "lat": 39.9850, "lng": -75.1280, "type": "RESIDENTIAL"},
79
+ {"name": "Port Richmond", "lat": 39.9870, "lng": -75.1120, "type": "RESIDENTIAL"},
80
+ # Note: Removed distant locations (Manayunk, Roxborough, Chestnut Hill, Mount Airy, Germantown)
81
+ # to keep the bounding box compact for faster OSMnx downloads
82
+ ],
83
+ }
84
+
85
+ # Hartford real locations
86
+ HARTFORD_REAL_LOCATIONS = {
87
+ "depots": [
88
+ {"name": "Downtown Hartford Depot", "lat": 41.7658, "lng": -72.6734},
89
+ {"name": "Asylum Hill Depot", "lat": 41.7700, "lng": -72.6900},
90
+ {"name": "South End Depot", "lat": 41.7400, "lng": -72.6750},
91
+ {"name": "West End Depot", "lat": 41.7680, "lng": -72.7100},
92
+ {"name": "Barry Square Depot", "lat": 41.7450, "lng": -72.6800},
93
+ {"name": "Clay Arsenal Depot", "lat": 41.7750, "lng": -72.6850},
94
+ ],
95
+ "visits": [
96
+ # Restaurants
97
+ {"name": "Max Downtown", "lat": 41.7670, "lng": -72.6730, "type": "RESTAURANT"},
98
+ {"name": "Trumbull Kitchen", "lat": 41.7650, "lng": -72.6750, "type": "RESTAURANT"},
99
+ {"name": "Salute", "lat": 41.7630, "lng": -72.6740, "type": "RESTAURANT"},
100
+ {"name": "Peppercorns Grill", "lat": 41.7690, "lng": -72.6680, "type": "RESTAURANT"},
101
+ {"name": "Feng Asian Bistro", "lat": 41.7640, "lng": -72.6725, "type": "RESTAURANT"},
102
+ {"name": "On20", "lat": 41.7655, "lng": -72.6728, "type": "RESTAURANT"},
103
+ {"name": "First and Last Tavern", "lat": 41.7620, "lng": -72.7050, "type": "RESTAURANT"},
104
+ {"name": "Agave Grill", "lat": 41.7580, "lng": -72.6820, "type": "RESTAURANT"},
105
+ {"name": "Bear's Smokehouse", "lat": 41.7550, "lng": -72.6780, "type": "RESTAURANT"},
106
+ {"name": "City Steam Brewery", "lat": 41.7630, "lng": -72.6750, "type": "RESTAURANT"},
107
+
108
+ # Businesses
109
+ {"name": "Travelers Tower", "lat": 41.7658, "lng": -72.6734, "type": "BUSINESS"},
110
+ {"name": "Hartford Steam Boiler", "lat": 41.7680, "lng": -72.6700, "type": "BUSINESS"},
111
+ {"name": "Aetna Building", "lat": 41.7700, "lng": -72.6900, "type": "BUSINESS"},
112
+ {"name": "Connecticut Convention Center", "lat": 41.7615, "lng": -72.6820, "type": "BUSINESS"},
113
+ {"name": "Hartford Hospital", "lat": 41.7547, "lng": -72.6858, "type": "BUSINESS"},
114
+ {"name": "Connecticut Children's", "lat": 41.7560, "lng": -72.6850, "type": "BUSINESS"},
115
+ {"name": "Trinity College", "lat": 41.7474, "lng": -72.6909, "type": "BUSINESS"},
116
+ {"name": "Connecticut Science Center", "lat": 41.7650, "lng": -72.6695, "type": "BUSINESS"},
117
+
118
+ # Residential
119
+ {"name": "West End Hartford", "lat": 41.7680, "lng": -72.7000, "type": "RESIDENTIAL"},
120
+ {"name": "Asylum Hill", "lat": 41.7720, "lng": -72.6850, "type": "RESIDENTIAL"},
121
+ {"name": "Frog Hollow", "lat": 41.7580, "lng": -72.6900, "type": "RESIDENTIAL"},
122
+ {"name": "Barry Square", "lat": 41.7450, "lng": -72.6800, "type": "RESIDENTIAL"},
123
+ {"name": "South End", "lat": 41.7400, "lng": -72.6750, "type": "RESIDENTIAL"},
124
+ {"name": "Blue Hills", "lat": 41.7850, "lng": -72.7050, "type": "RESIDENTIAL"},
125
+ {"name": "Parkville", "lat": 41.7650, "lng": -72.7100, "type": "RESIDENTIAL"},
126
+ {"name": "Behind the Rocks", "lat": 41.7550, "lng": -72.7050, "type": "RESIDENTIAL"},
127
+ {"name": "Charter Oak", "lat": 41.7495, "lng": -72.6650, "type": "RESIDENTIAL"},
128
+ {"name": "Sheldon Charter Oak", "lat": 41.7510, "lng": -72.6700, "type": "RESIDENTIAL"},
129
+ {"name": "Clay Arsenal", "lat": 41.7750, "lng": -72.6850, "type": "RESIDENTIAL"},
130
+ {"name": "Upper Albany", "lat": 41.7780, "lng": -72.6950, "type": "RESIDENTIAL"},
131
+ ],
132
+ }
133
+
134
+ # Florence real locations
135
+ FIRENZE_REAL_LOCATIONS = {
136
+ "depots": [
137
+ {"name": "Centro Storico Depot", "lat": 43.7696, "lng": 11.2558},
138
+ {"name": "Santa Maria Novella Depot", "lat": 43.7745, "lng": 11.2487},
139
+ {"name": "Campo di Marte Depot", "lat": 43.7820, "lng": 11.2820},
140
+ {"name": "Rifredi Depot", "lat": 43.7950, "lng": 11.2410},
141
+ {"name": "Novoli Depot", "lat": 43.7880, "lng": 11.2220},
142
+ {"name": "Gavinana Depot", "lat": 43.7520, "lng": 11.2680},
143
+ ],
144
+ "visits": [
145
+ # Restaurants
146
+ {"name": "Trattoria Mario", "lat": 43.7750, "lng": 11.2530, "type": "RESTAURANT"},
147
+ {"name": "Buca Mario", "lat": 43.7698, "lng": 11.2505, "type": "RESTAURANT"},
148
+ {"name": "Il Latini", "lat": 43.7705, "lng": 11.2495, "type": "RESTAURANT"},
149
+ {"name": "Osteria dell'Enoteca", "lat": 43.7680, "lng": 11.2545, "type": "RESTAURANT"},
150
+ {"name": "Trattoria Sostanza", "lat": 43.7735, "lng": 11.2470, "type": "RESTAURANT"},
151
+ {"name": "All'Antico Vinaio", "lat": 43.7690, "lng": 11.2570, "type": "RESTAURANT"},
152
+ {"name": "Mercato Centrale", "lat": 43.7762, "lng": 11.2540, "type": "RESTAURANT"},
153
+ {"name": "Cibreo", "lat": 43.7702, "lng": 11.2670, "type": "RESTAURANT"},
154
+ {"name": "Ora d'Aria", "lat": 43.7710, "lng": 11.2610, "type": "RESTAURANT"},
155
+ {"name": "Buca Lapi", "lat": 43.7720, "lng": 11.2535, "type": "RESTAURANT"},
156
+ {"name": "Il Palagio", "lat": 43.7680, "lng": 11.2550, "type": "RESTAURANT"},
157
+ {"name": "Enoteca Pinchiorri", "lat": 43.7695, "lng": 11.2620, "type": "RESTAURANT"},
158
+ {"name": "La Giostra", "lat": 43.7745, "lng": 11.2650, "type": "RESTAURANT"},
159
+ {"name": "Fishing Lab", "lat": 43.7730, "lng": 11.2560, "type": "RESTAURANT"},
160
+ {"name": "Trattoria Cammillo", "lat": 43.7665, "lng": 11.2520, "type": "RESTAURANT"},
161
+
162
+ # Businesses
163
+ {"name": "Palazzo Vecchio", "lat": 43.7693, "lng": 11.2563, "type": "BUSINESS"},
164
+ {"name": "Uffizi Gallery", "lat": 43.7677, "lng": 11.2553, "type": "BUSINESS"},
165
+ {"name": "Gucci Garden", "lat": 43.7692, "lng": 11.2556, "type": "BUSINESS"},
166
+ {"name": "Ferragamo Museum", "lat": 43.7700, "lng": 11.2530, "type": "BUSINESS"},
167
+ {"name": "Ospedale Santa Maria", "lat": 43.7830, "lng": 11.2690, "type": "BUSINESS"},
168
+ {"name": "Universita degli Studi", "lat": 43.7765, "lng": 11.2555, "type": "BUSINESS"},
169
+ {"name": "Palazzo Strozzi", "lat": 43.7706, "lng": 11.2515, "type": "BUSINESS"},
170
+ {"name": "Biblioteca Nazionale", "lat": 43.7660, "lng": 11.2650, "type": "BUSINESS"},
171
+ {"name": "Teatro del Maggio", "lat": 43.7780, "lng": 11.2470, "type": "BUSINESS"},
172
+ {"name": "Palazzo Pitti", "lat": 43.7650, "lng": 11.2500, "type": "BUSINESS"},
173
+ {"name": "Accademia Gallery", "lat": 43.7768, "lng": 11.2590, "type": "BUSINESS"},
174
+ {"name": "Ospedale Meyer", "lat": 43.7910, "lng": 11.2520, "type": "BUSINESS"},
175
+ {"name": "Polo Universitario", "lat": 43.7920, "lng": 11.2180, "type": "BUSINESS"},
176
+
177
+ # Residential
178
+ {"name": "Santo Spirito", "lat": 43.7665, "lng": 11.2470, "type": "RESIDENTIAL"},
179
+ {"name": "San Frediano", "lat": 43.7680, "lng": 11.2420, "type": "RESIDENTIAL"},
180
+ {"name": "Santa Croce", "lat": 43.7688, "lng": 11.2620, "type": "RESIDENTIAL"},
181
+ {"name": "San Lorenzo", "lat": 43.7755, "lng": 11.2540, "type": "RESIDENTIAL"},
182
+ {"name": "San Marco", "lat": 43.7780, "lng": 11.2585, "type": "RESIDENTIAL"},
183
+ {"name": "Sant'Ambrogio", "lat": 43.7705, "lng": 11.2680, "type": "RESIDENTIAL"},
184
+ {"name": "Campo di Marte", "lat": 43.7820, "lng": 11.2820, "type": "RESIDENTIAL"},
185
+ {"name": "Novoli", "lat": 43.7880, "lng": 11.2220, "type": "RESIDENTIAL"},
186
+ {"name": "Rifredi", "lat": 43.7950, "lng": 11.2410, "type": "RESIDENTIAL"},
187
+ {"name": "Le Cure", "lat": 43.7890, "lng": 11.2580, "type": "RESIDENTIAL"},
188
+ {"name": "Careggi", "lat": 43.8020, "lng": 11.2530, "type": "RESIDENTIAL"},
189
+ {"name": "Peretola", "lat": 43.7960, "lng": 11.2050, "type": "RESIDENTIAL"},
190
+ {"name": "Isolotto", "lat": 43.7620, "lng": 11.2200, "type": "RESIDENTIAL"},
191
+ {"name": "Gavinana", "lat": 43.7520, "lng": 11.2680, "type": "RESIDENTIAL"},
192
+ {"name": "Galluzzo", "lat": 43.7400, "lng": 11.2480, "type": "RESIDENTIAL"},
193
+ {"name": "Porta Romana", "lat": 43.7610, "lng": 11.2560, "type": "RESIDENTIAL"},
194
+ {"name": "Bellosguardo", "lat": 43.7650, "lng": 11.2350, "type": "RESIDENTIAL"},
195
+ {"name": "Arcetri", "lat": 43.7500, "lng": 11.2530, "type": "RESIDENTIAL"},
196
+ {"name": "Fiesole", "lat": 43.8055, "lng": 11.2935, "type": "RESIDENTIAL"},
197
+ {"name": "Settignano", "lat": 43.7850, "lng": 11.3100, "type": "RESIDENTIAL"},
198
+ ],
199
+ }
200
+
201
+ # Map demo data enum names to their real location data
202
+ REAL_LOCATION_DATA = {
203
+ "PHILADELPHIA": PHILADELPHIA_REAL_LOCATIONS,
204
+ "HARTFORT": HARTFORD_REAL_LOCATIONS,
205
+ "FIRENZE": FIRENZE_REAL_LOCATIONS,
206
+ }
207
+
208
+ # Vehicle names using phonetic alphabet for clear identification
209
+ VEHICLE_NAMES = ("Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel", "India", "Juliet")
210
+
211
+
212
+ class CustomerType(Enum):
213
+ """
214
+ Customer types with realistic time windows, demand patterns, and service durations.
215
+
216
+ Each customer type reflects real-world delivery scenarios:
217
+ - RESIDENTIAL: Evening deliveries when people are home from work (5-10 min unload)
218
+ - BUSINESS: Standard business hours with larger orders (15-30 min unload, paperwork)
219
+ - RESTAURANT: Early morning before lunch prep rush (20-40 min for bulk unload, inspection)
220
+ """
221
+ # (label, window_start, window_end, min_demand, max_demand, min_service_min, max_service_min)
222
+ RESIDENTIAL = ("residential", time(17, 0), time(20, 0), 1, 2, 5, 10)
223
+ BUSINESS = ("business", time(9, 0), time(17, 0), 3, 6, 15, 30)
224
+ RESTAURANT = ("restaurant", time(6, 0), time(10, 0), 5, 10, 20, 40)
225
+
226
+ def __init__(self, label: str, window_start: time, window_end: time,
227
+ min_demand: int, max_demand: int, min_service_minutes: int, max_service_minutes: int):
228
+ self.label = label
229
+ self.window_start = window_start
230
+ self.window_end = window_end
231
+ self.min_demand = min_demand
232
+ self.max_demand = max_demand
233
+ self.min_service_minutes = min_service_minutes
234
+ self.max_service_minutes = max_service_minutes
235
+
236
+
237
+ # Weighted distribution: 50% residential, 30% business, 20% restaurant
238
+ CUSTOMER_TYPE_WEIGHTS = [
239
+ (CustomerType.RESIDENTIAL, 50),
240
+ (CustomerType.BUSINESS, 30),
241
+ (CustomerType.RESTAURANT, 20),
242
+ ]
243
+
244
+
245
+ def random_customer_type(random: Random) -> CustomerType:
246
+ """Weighted random selection of customer type."""
247
+ total = sum(w for _, w in CUSTOMER_TYPE_WEIGHTS)
248
+ r = random.randint(1, total)
249
+ cumulative = 0
250
+ for ctype, weight in CUSTOMER_TYPE_WEIGHTS:
251
+ cumulative += weight
252
+ if r <= cumulative:
253
+ return ctype
254
+ return CustomerType.RESIDENTIAL # fallback
255
+
256
+
257
+ @dataclass
258
+ class _DemoDataProperties:
259
+ seed: int
260
+ visit_count: int
261
+ vehicle_count: int
262
+ vehicle_start_time: time
263
+ min_vehicle_capacity: int
264
+ max_vehicle_capacity: int
265
+ south_west_corner: Location
266
+ north_east_corner: Location
267
+
268
+ def __post_init__(self):
269
+ if self.min_vehicle_capacity < 1:
270
+ raise ValueError(f"Number of minVehicleCapacity ({self.min_vehicle_capacity}) must be greater than zero.")
271
+ if self.max_vehicle_capacity < 1:
272
+ raise ValueError(f"Number of maxVehicleCapacity ({self.max_vehicle_capacity}) must be greater than zero.")
273
+ if self.min_vehicle_capacity >= self.max_vehicle_capacity:
274
+ raise ValueError(f"maxVehicleCapacity ({self.max_vehicle_capacity}) must be greater than "
275
+ f"minVehicleCapacity ({self.min_vehicle_capacity}).")
276
+ if self.visit_count < 1:
277
+ raise ValueError(f"Number of visitCount ({self.visit_count}) must be greater than zero.")
278
+ if self.vehicle_count < 1:
279
+ raise ValueError(f"Number of vehicleCount ({self.vehicle_count}) must be greater than zero.")
280
+ if self.north_east_corner.latitude <= self.south_west_corner.latitude:
281
+ raise ValueError(f"northEastCorner.getLatitude ({self.north_east_corner.latitude}) must be greater than "
282
+ f"southWestCorner.getLatitude({self.south_west_corner.latitude}).")
283
+ if self.north_east_corner.longitude <= self.south_west_corner.longitude:
284
+ raise ValueError(f"northEastCorner.getLongitude ({self.north_east_corner.longitude}) must be greater than "
285
+ f"southWestCorner.getLongitude({self.south_west_corner.longitude}).")
286
+
287
+
288
+ class DemoData(Enum):
289
+ # Bounding boxes tightened to ~5x5 km around actual location data
290
+ # for faster OSMnx network downloads (smaller area = faster download)
291
+
292
+ # Philadelphia: Center City area (~39.92 to 39.99 lat, -75.22 to -75.11 lng)
293
+ PHILADELPHIA = _DemoDataProperties(0, 55, 6, time(6, 0),
294
+ 15, 30,
295
+ Location(latitude=39.92,
296
+ longitude=-75.23),
297
+ Location(latitude=40.00,
298
+ longitude=-75.11))
299
+
300
+ # Hartford: Downtown area (~41.69 to 41.79 lat, -72.75 to -72.60 lng)
301
+ HARTFORT = _DemoDataProperties(1, 50, 6, time(6, 0),
302
+ 20, 30,
303
+ Location(latitude=41.69,
304
+ longitude=-72.75),
305
+ Location(latitude=41.79,
306
+ longitude=-72.60))
307
+
308
+ # Firenze: Historic center area
309
+ FIRENZE = _DemoDataProperties(2, 77, 6, time(6, 0),
310
+ 20, 40,
311
+ Location(latitude=43.73,
312
+ longitude=11.17),
313
+ Location(latitude=43.81,
314
+ longitude=11.32))
315
+
316
+
317
+ def doubles(random: Random, start: float, end: float) -> Generator[float, None, None]:
318
+ while True:
319
+ yield random.uniform(start, end)
320
+
321
+
322
+ def ints(random: Random, start: int, end: int) -> Generator[int, None, None]:
323
+ while True:
324
+ yield random.randrange(start, end)
325
+
326
+
327
+ T = TypeVar('T')
328
+
329
+
330
+ def values(random: Random, sequence: Sequence[T]) -> Generator[T, None, None]:
331
+ start = 0
332
+ end = len(sequence) - 1
333
+ while True:
334
+ yield sequence[random.randint(start, end)]
335
+
336
+
337
+ def generate_names(random: Random) -> Generator[str, None, None]:
338
+ while True:
339
+ yield f'{random.choice(FIRST_NAMES)} {random.choice(LAST_NAMES)}'
340
+
341
+
342
+ def generate_demo_data(demo_data_enum: DemoData) -> VehicleRoutePlan:
343
+ """
344
+ Generate demo data for vehicle routing using real street addresses.
345
+
346
+ Uses actual locations on the road network for realistic routing:
347
+ - Residential (50%): Evening windows (17:00-20:00), small orders (1-2 units)
348
+ - Business (30%): Business hours (09:00-17:00), medium orders (3-6 units)
349
+ - Restaurant (20%): Early morning (06:00-10:00), large orders (5-10 units)
350
+
351
+ Args:
352
+ demo_data_enum: The demo data configuration to use
353
+ """
354
+ name = "demo"
355
+ demo_data = demo_data_enum.value
356
+ random = Random(demo_data.seed)
357
+
358
+ # Get real location data for this demo
359
+ real_locations = REAL_LOCATION_DATA.get(demo_data_enum.name)
360
+
361
+ vehicle_capacities = ints(random, demo_data.min_vehicle_capacity,
362
+ demo_data.max_vehicle_capacity + 1)
363
+
364
+ if real_locations:
365
+ # Use real depot locations
366
+ depot_locations = real_locations["depots"]
367
+ vehicles = []
368
+ for i in range(demo_data.vehicle_count):
369
+ depot = depot_locations[i % len(depot_locations)]
370
+ vehicles.append(
371
+ Vehicle(
372
+ id=str(i),
373
+ name=VEHICLE_NAMES[i % len(VEHICLE_NAMES)],
374
+ capacity=next(vehicle_capacities),
375
+ home_location=Location(latitude=depot["lat"], longitude=depot["lng"]),
376
+ departure_time=datetime.combine(
377
+ date.today() + timedelta(days=1), demo_data.vehicle_start_time
378
+ ),
379
+ )
380
+ )
381
+ else:
382
+ # Fallback to random locations within bounding box
383
+ latitudes = doubles(random, demo_data.south_west_corner.latitude, demo_data.north_east_corner.latitude)
384
+ longitudes = doubles(random, demo_data.south_west_corner.longitude, demo_data.north_east_corner.longitude)
385
+ vehicles = [
386
+ Vehicle(
387
+ id=str(i),
388
+ name=VEHICLE_NAMES[i % len(VEHICLE_NAMES)],
389
+ capacity=next(vehicle_capacities),
390
+ home_location=Location(latitude=next(latitudes), longitude=next(longitudes)),
391
+ departure_time=datetime.combine(
392
+ date.today() + timedelta(days=1), demo_data.vehicle_start_time
393
+ ),
394
+ )
395
+ for i in range(demo_data.vehicle_count)
396
+ ]
397
+
398
+ tomorrow = date.today() + timedelta(days=1)
399
+ visits = []
400
+
401
+ if real_locations:
402
+ # Use real visit locations with their actual types
403
+ visit_locations = real_locations["visits"]
404
+ # Shuffle to get variety, but use seed for reproducibility
405
+ shuffled_visits = list(visit_locations)
406
+ random.shuffle(shuffled_visits)
407
+
408
+ for i in range(min(demo_data.visit_count, len(shuffled_visits))):
409
+ loc_data = shuffled_visits[i]
410
+ # Get customer type from location data
411
+ ctype_name = loc_data.get("type", "RESIDENTIAL")
412
+ ctype = CustomerType[ctype_name]
413
+ service_minutes = random.randint(ctype.min_service_minutes, ctype.max_service_minutes)
414
+
415
+ visits.append(
416
+ Visit(
417
+ id=str(i),
418
+ name=loc_data["name"],
419
+ location=Location(latitude=loc_data["lat"], longitude=loc_data["lng"]),
420
+ demand=random.randint(ctype.min_demand, ctype.max_demand),
421
+ min_start_time=datetime.combine(tomorrow, ctype.window_start),
422
+ max_end_time=datetime.combine(tomorrow, ctype.window_end),
423
+ service_duration=timedelta(minutes=service_minutes),
424
+ )
425
+ )
426
+
427
+ # If we need more visits than we have real locations, generate additional random ones
428
+ if demo_data.visit_count > len(shuffled_visits):
429
+ names = generate_names(random)
430
+ latitudes = doubles(random, demo_data.south_west_corner.latitude, demo_data.north_east_corner.latitude)
431
+ longitudes = doubles(random, demo_data.south_west_corner.longitude, demo_data.north_east_corner.longitude)
432
+
433
+ for i in range(len(shuffled_visits), demo_data.visit_count):
434
+ ctype = random_customer_type(random)
435
+ service_minutes = random.randint(ctype.min_service_minutes, ctype.max_service_minutes)
436
+ visits.append(
437
+ Visit(
438
+ id=str(i),
439
+ name=next(names),
440
+ location=Location(latitude=next(latitudes), longitude=next(longitudes)),
441
+ demand=random.randint(ctype.min_demand, ctype.max_demand),
442
+ min_start_time=datetime.combine(tomorrow, ctype.window_start),
443
+ max_end_time=datetime.combine(tomorrow, ctype.window_end),
444
+ service_duration=timedelta(minutes=service_minutes),
445
+ )
446
+ )
447
+ else:
448
+ # Fallback to fully random locations
449
+ names = generate_names(random)
450
+ latitudes = doubles(random, demo_data.south_west_corner.latitude, demo_data.north_east_corner.latitude)
451
+ longitudes = doubles(random, demo_data.south_west_corner.longitude, demo_data.north_east_corner.longitude)
452
+
453
+ for i in range(demo_data.visit_count):
454
+ ctype = random_customer_type(random)
455
+ service_minutes = random.randint(ctype.min_service_minutes, ctype.max_service_minutes)
456
+ visits.append(
457
+ Visit(
458
+ id=str(i),
459
+ name=next(names),
460
+ location=Location(latitude=next(latitudes), longitude=next(longitudes)),
461
+ demand=random.randint(ctype.min_demand, ctype.max_demand),
462
+ min_start_time=datetime.combine(tomorrow, ctype.window_start),
463
+ max_end_time=datetime.combine(tomorrow, ctype.window_end),
464
+ service_duration=timedelta(minutes=service_minutes),
465
+ )
466
+ )
467
+
468
+ return VehicleRoutePlan(
469
+ name=name,
470
+ south_west_corner=demo_data.south_west_corner,
471
+ north_east_corner=demo_data.north_east_corner,
472
+ vehicles=vehicles,
473
+ visits=visits,
474
+ )
475
+
476
+
477
+ def tomorrow_at(local_time: time) -> datetime:
478
+ return datetime.combine(date.today(), local_time)
src/vehicle_routing/domain.py ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver import SolverStatus
2
+ from solverforge_legacy.solver.score import HardSoftScore
3
+ from solverforge_legacy.solver.domain import (
4
+ planning_entity,
5
+ planning_solution,
6
+ PlanningId,
7
+ PlanningScore,
8
+ PlanningListVariable,
9
+ PlanningEntityCollectionProperty,
10
+ ValueRangeProvider,
11
+ InverseRelationShadowVariable,
12
+ PreviousElementShadowVariable,
13
+ NextElementShadowVariable,
14
+ CascadingUpdateShadowVariable,
15
+ )
16
+
17
+ from datetime import datetime, timedelta
18
+ from typing import Annotated, Optional, List, Union, ClassVar, TYPE_CHECKING
19
+ from dataclasses import dataclass, field
20
+ from .json_serialization import JsonDomainBase
21
+ from pydantic import Field
22
+
23
+ if TYPE_CHECKING:
24
+ from .routing import DistanceMatrix
25
+
26
+
27
+ @dataclass
28
+ class Location:
29
+ """
30
+ Represents a geographic location with latitude and longitude.
31
+
32
+ Driving times can be computed using either:
33
+ 1. A precomputed distance matrix (if set) - uses real road network data
34
+ 2. The Haversine formula (fallback) - uses great-circle distance
35
+ """
36
+ latitude: float
37
+ longitude: float
38
+
39
+ # Class-level distance matrix (injected at problem load time)
40
+ _distance_matrix: ClassVar[Optional["DistanceMatrix"]] = None
41
+
42
+ # Earth radius in meters
43
+ _EARTH_RADIUS_M = 6371000
44
+ _TWICE_EARTH_RADIUS_M = 2 * _EARTH_RADIUS_M
45
+ # Average driving speed assumption: 50 km/h
46
+ _AVERAGE_SPEED_KMPH = 50
47
+
48
+ @classmethod
49
+ def set_distance_matrix(cls, matrix: "DistanceMatrix") -> None:
50
+ """Inject a precomputed distance matrix for real road routing."""
51
+ cls._distance_matrix = matrix
52
+
53
+ @classmethod
54
+ def clear_distance_matrix(cls) -> None:
55
+ """Clear the distance matrix (reverts to haversine fallback)."""
56
+ cls._distance_matrix = None
57
+
58
+ @classmethod
59
+ def get_distance_matrix(cls) -> Optional["DistanceMatrix"]:
60
+ """Get the current distance matrix, if any."""
61
+ return cls._distance_matrix
62
+
63
+ def driving_time_to(self, other: "Location") -> int:
64
+ """
65
+ Get driving time in seconds to another location.
66
+
67
+ Uses the precomputed distance matrix if available, otherwise
68
+ falls back to haversine calculation.
69
+ """
70
+ if self._distance_matrix is not None:
71
+ return self._distance_matrix.get_driving_time(self, other)
72
+ return self._calculate_driving_time_haversine(other)
73
+
74
+ def _calculate_driving_time_haversine(self, other: "Location") -> int:
75
+ """
76
+ Calculate driving time in seconds using Haversine distance.
77
+
78
+ Algorithm:
79
+ 1. Convert lat/long to 3D Cartesian coordinates on a unit sphere
80
+ 2. Calculate Euclidean distance between the two points
81
+ 3. Use the arc sine formula to get the great-circle distance
82
+ 4. Convert meters to driving seconds assuming average speed
83
+ """
84
+ if self.latitude == other.latitude and self.longitude == other.longitude:
85
+ return 0
86
+
87
+ from_cartesian = self._to_cartesian()
88
+ to_cartesian = other._to_cartesian()
89
+ distance_meters = self._calculate_distance(from_cartesian, to_cartesian)
90
+ return self._meters_to_driving_seconds(distance_meters)
91
+
92
+ def _to_cartesian(self) -> tuple[float, float, float, float]:
93
+ """Convert latitude/longitude to 3D Cartesian coordinates on a unit sphere."""
94
+ import math
95
+ lat_rad = math.radians(self.latitude)
96
+ lon_rad = math.radians(self.longitude)
97
+ # Cartesian coordinates, normalized for a sphere of diameter 1.0
98
+ x = 0.5 * math.cos(lat_rad) * math.sin(lon_rad)
99
+ y = 0.5 * math.cos(lat_rad) * math.cos(lon_rad)
100
+ z = 0.5 * math.sin(lat_rad)
101
+ return (x, y, z)
102
+
103
+ def _calculate_distance(self, from_c: tuple[float, float, float, float], to_c: tuple[float, float, float, float]) -> int:
104
+ """Calculate great-circle distance in meters between two Cartesian points."""
105
+ import math
106
+ dx = from_c[0] - to_c[0]
107
+ dy = from_c[1] - to_c[1]
108
+ dz = from_c[2] - to_c[2]
109
+ r = math.sqrt(dx * dx + dy * dy + dz * dz)
110
+ return round(self._TWICE_EARTH_RADIUS_M * math.asin(r))
111
+
112
+ @classmethod
113
+ def _meters_to_driving_seconds(cls, meters: int) -> int:
114
+ """Convert distance in meters to driving time in seconds."""
115
+ # Formula: seconds = meters / (km/h) * 3.6
116
+ # This is equivalent to: seconds = meters / (speed_m_per_s)
117
+ # where speed_m_per_s = km/h / 3.6
118
+ return round(meters / cls._AVERAGE_SPEED_KMPH * 3.6)
119
+
120
+ def __str__(self):
121
+ return f"[{self.latitude}, {self.longitude}]"
122
+
123
+ def __repr__(self):
124
+ return f"Location({self.latitude}, {self.longitude})"
125
+
126
+
127
+ @planning_entity
128
+ @dataclass
129
+ class Visit:
130
+ id: Annotated[str, PlanningId]
131
+ name: str
132
+ location: Location
133
+ demand: int
134
+ min_start_time: datetime
135
+ max_end_time: datetime
136
+ service_duration: timedelta
137
+ vehicle: Annotated[
138
+ Optional["Vehicle"],
139
+ InverseRelationShadowVariable(source_variable_name="visits"),
140
+ ] = None
141
+ previous_visit: Annotated[
142
+ Optional["Visit"], PreviousElementShadowVariable(source_variable_name="visits")
143
+ ] = None
144
+ next_visit: Annotated[
145
+ Optional["Visit"], NextElementShadowVariable(source_variable_name="visits")
146
+ ] = None
147
+ arrival_time: Annotated[
148
+ Optional[datetime],
149
+ CascadingUpdateShadowVariable(target_method_name="update_arrival_time"),
150
+ ] = None
151
+
152
+ def update_arrival_time(self):
153
+ if self.vehicle is None or (
154
+ self.previous_visit is not None and self.previous_visit.arrival_time is None
155
+ ):
156
+ self.arrival_time = None
157
+ elif self.previous_visit is None:
158
+ self.arrival_time = self.vehicle.departure_time + timedelta(
159
+ seconds=self.vehicle.home_location.driving_time_to(self.location)
160
+ )
161
+ else:
162
+ self.arrival_time = (
163
+ self.previous_visit.calculate_departure_time()
164
+ + timedelta(
165
+ seconds=self.previous_visit.location.driving_time_to(self.location)
166
+ )
167
+ )
168
+
169
+ def calculate_departure_time(self):
170
+ if self.arrival_time is None:
171
+ return None
172
+
173
+ return max(self.arrival_time, self.min_start_time) + self.service_duration
174
+
175
+ @property
176
+ def departure_time(self) -> Optional[datetime]:
177
+ return self.calculate_departure_time()
178
+
179
+ @property
180
+ def start_service_time(self) -> Optional[datetime]:
181
+ if self.arrival_time is None:
182
+ return None
183
+ return max(self.arrival_time, self.min_start_time)
184
+
185
+ def is_service_finished_after_max_end_time(self) -> bool:
186
+ return (
187
+ self.arrival_time is not None
188
+ and self.calculate_departure_time() > self.max_end_time
189
+ )
190
+
191
+ def service_finished_delay_in_minutes(self) -> int:
192
+ if self.arrival_time is None:
193
+ return 0
194
+ # Round up to next minute using the negative division trick:
195
+ # ex: 30 seconds / -1 minute = -0.5,
196
+ # so 30 seconds // -1 minute = -1,
197
+ # and negating that gives 1
198
+ return -(
199
+ (self.calculate_departure_time() - self.max_end_time)
200
+ // timedelta(minutes=-1)
201
+ )
202
+
203
+ @property
204
+ def driving_time_seconds_from_previous_standstill(self) -> Optional[int]:
205
+ if self.vehicle is None:
206
+ return None
207
+
208
+ if self.previous_visit is None:
209
+ return self.vehicle.home_location.driving_time_to(self.location)
210
+ else:
211
+ return self.previous_visit.location.driving_time_to(self.location)
212
+
213
+ def __str__(self):
214
+ return self.id
215
+
216
+ def __repr__(self):
217
+ return f"Visit({self.id})"
218
+
219
+
220
+ @planning_entity
221
+ @dataclass
222
+ class Vehicle:
223
+ id: Annotated[str, PlanningId]
224
+ name: str
225
+ capacity: int
226
+ home_location: Location
227
+ departure_time: datetime
228
+ visits: Annotated[list[Visit], PlanningListVariable] = field(default_factory=list)
229
+
230
+ @property
231
+ def arrival_time(self) -> datetime:
232
+ if len(self.visits) == 0:
233
+ return self.departure_time
234
+ return self.visits[-1].departure_time + timedelta(
235
+ seconds=self.visits[-1].location.driving_time_to(self.home_location)
236
+ )
237
+
238
+ @property
239
+ def total_demand(self) -> int:
240
+ return self.calculate_total_demand()
241
+
242
+ @property
243
+ def total_driving_time_seconds(self) -> int:
244
+ return self.calculate_total_driving_time_seconds()
245
+
246
+ def calculate_total_demand(self) -> int:
247
+ total_demand = 0
248
+ for visit in self.visits:
249
+ total_demand += visit.demand
250
+ return total_demand
251
+
252
+ def calculate_total_driving_time_seconds(self) -> int:
253
+ if len(self.visits) == 0:
254
+ return 0
255
+ total_driving_time_seconds = 0
256
+ previous_location = self.home_location
257
+
258
+ for visit in self.visits:
259
+ total_driving_time_seconds += previous_location.driving_time_to(
260
+ visit.location
261
+ )
262
+ previous_location = visit.location
263
+
264
+ total_driving_time_seconds += previous_location.driving_time_to(
265
+ self.home_location
266
+ )
267
+ return total_driving_time_seconds
268
+
269
+ def __str__(self):
270
+ return self.name
271
+
272
+ def __repr__(self):
273
+ return f"Vehicle({self.id}, {self.name})"
274
+
275
+
276
+ @planning_solution
277
+ @dataclass
278
+ class VehicleRoutePlan:
279
+ name: str
280
+ south_west_corner: Location
281
+ north_east_corner: Location
282
+ vehicles: Annotated[list[Vehicle], PlanningEntityCollectionProperty]
283
+ visits: Annotated[list[Visit], PlanningEntityCollectionProperty, ValueRangeProvider]
284
+ score: Annotated[Optional[HardSoftScore], PlanningScore] = None
285
+ solver_status: SolverStatus = SolverStatus.NOT_SOLVING
286
+
287
+ @property
288
+ def total_driving_time_seconds(self) -> int:
289
+ out = 0
290
+ for vehicle in self.vehicles:
291
+ out += vehicle.total_driving_time_seconds
292
+ return out
293
+
294
+ @property
295
+ def start_date_time(self) -> Optional[datetime]:
296
+ """Earliest vehicle departure time - for timeline window."""
297
+ if not self.vehicles:
298
+ return None
299
+ return min(v.departure_time for v in self.vehicles)
300
+
301
+ @property
302
+ def end_date_time(self) -> Optional[datetime]:
303
+ """Latest vehicle arrival time - for timeline window."""
304
+ if not self.vehicles:
305
+ return None
306
+ return max(v.arrival_time for v in self.vehicles)
307
+
308
+ def __str__(self):
309
+ return f"VehicleRoutePlan(name={self.name}, vehicles={self.vehicles}, visits={self.visits})"
310
+
311
+
312
+ # Pydantic REST models for API (used for deserialization and context)
313
+ class LocationModel(JsonDomainBase):
314
+ latitude: float
315
+ longitude: float
316
+
317
+
318
+ class VisitModel(JsonDomainBase):
319
+ id: str
320
+ name: str
321
+ location: List[float] # [lat, lng] array
322
+ demand: int
323
+ min_start_time: str = Field(..., alias="minStartTime") # ISO datetime string
324
+ max_end_time: str = Field(..., alias="maxEndTime") # ISO datetime string
325
+ service_duration: int = Field(..., alias="serviceDuration") # Duration in seconds
326
+ vehicle: Union[str, "VehicleModel", None] = None
327
+ previous_visit: Union[str, "VisitModel", None] = Field(None, alias="previousVisit")
328
+ next_visit: Union[str, "VisitModel", None] = Field(None, alias="nextVisit")
329
+ arrival_time: Optional[str] = Field(
330
+ None, alias="arrivalTime"
331
+ ) # ISO datetime string
332
+ start_service_time: Optional[str] = Field(
333
+ None, alias="startServiceTime"
334
+ ) # ISO datetime string
335
+ departure_time: Optional[str] = Field(
336
+ None, alias="departureTime"
337
+ ) # ISO datetime string
338
+ driving_time_seconds_from_previous_standstill: Optional[int] = Field(
339
+ None, alias="drivingTimeSecondsFromPreviousStandstill"
340
+ )
341
+
342
+
343
+ class VehicleModel(JsonDomainBase):
344
+ id: str
345
+ name: str
346
+ capacity: int
347
+ home_location: List[float] = Field(..., alias="homeLocation") # [lat, lng] array
348
+ departure_time: str = Field(..., alias="departureTime") # ISO datetime string
349
+ visits: List[Union[str, VisitModel]] = Field(default_factory=list)
350
+ total_demand: int = Field(0, alias="totalDemand")
351
+ total_driving_time_seconds: int = Field(0, alias="totalDrivingTimeSeconds")
352
+ arrival_time: Optional[str] = Field(
353
+ None, alias="arrivalTime"
354
+ ) # ISO datetime string
355
+
356
+
357
+ class VehicleRoutePlanModel(JsonDomainBase):
358
+ name: str
359
+ south_west_corner: List[float] = Field(
360
+ ..., alias="southWestCorner"
361
+ ) # [lat, lng] array
362
+ north_east_corner: List[float] = Field(
363
+ ..., alias="northEastCorner"
364
+ ) # [lat, lng] array
365
+ vehicles: List[VehicleModel]
366
+ visits: List[VisitModel]
367
+ score: Optional[str] = None
368
+ solver_status: Optional[str] = None
369
+ total_driving_time_seconds: int = Field(0, alias="totalDrivingTimeSeconds")
370
+ start_date_time: Optional[str] = Field(None, alias="startDateTime")
371
+ end_date_time: Optional[str] = Field(None, alias="endDateTime")
src/vehicle_routing/json_serialization.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver.score import HardSoftScore
2
+
3
+ from typing import Any
4
+ from datetime import timedelta
5
+ from pydantic import (
6
+ BaseModel,
7
+ ConfigDict,
8
+ PlainSerializer,
9
+ BeforeValidator,
10
+ ValidationInfo,
11
+ )
12
+ from pydantic.alias_generators import to_camel
13
+
14
+
15
+ class JsonDomainBase(BaseModel):
16
+ model_config = ConfigDict(
17
+ alias_generator=to_camel,
18
+ populate_by_name=True,
19
+ from_attributes=True,
20
+ )
21
+
22
+
23
+ def make_id_item_validator(key: str):
24
+ def validator(v: Any, info: ValidationInfo) -> Any:
25
+ if v is None:
26
+ return None
27
+
28
+ if not isinstance(v, str) or not info.context:
29
+ return v
30
+
31
+ return info.context.get(key)[v]
32
+
33
+ return BeforeValidator(validator)
34
+
35
+
36
+ def make_id_list_item_validator(key: str):
37
+ def validator(v: Any, info: ValidationInfo) -> Any:
38
+ if v is None:
39
+ return None
40
+
41
+ if isinstance(v, (list, tuple)):
42
+ out = []
43
+ for item in v:
44
+ if not isinstance(v, str) or not info.context:
45
+ return v
46
+ out.append(info.context.get(key)[item])
47
+ return out
48
+
49
+ return v
50
+
51
+ return BeforeValidator(validator)
52
+
53
+
54
+ LocationSerializer = PlainSerializer(
55
+ lambda location: [
56
+ location.latitude,
57
+ location.longitude,
58
+ ],
59
+ return_type=list[float],
60
+ )
61
+ ScoreSerializer = PlainSerializer(lambda score: str(score), return_type=str)
62
+ IdSerializer = PlainSerializer(
63
+ lambda item: item.id if item is not None else None, return_type=str | None
64
+ )
65
+ IdListSerializer = PlainSerializer(
66
+ lambda items: [item.id for item in items], return_type=list
67
+ )
68
+ DurationSerializer = PlainSerializer(
69
+ lambda duration: duration // timedelta(seconds=1), return_type=int
70
+ )
71
+
72
+ VisitListValidator = make_id_list_item_validator("visits")
73
+ VisitValidator = make_id_item_validator("visits")
74
+ VehicleValidator = make_id_item_validator("vehicles")
75
+
76
+
77
+ def validate_score(v: Any, info: ValidationInfo) -> Any:
78
+ if isinstance(v, HardSoftScore) or v is None:
79
+ return v
80
+ if isinstance(v, str):
81
+ return HardSoftScore.parse(v)
82
+ raise ValueError('"score" should be a string')
83
+
84
+
85
+ ScoreValidator = BeforeValidator(validate_score)
src/vehicle_routing/rest_api.py ADDED
@@ -0,0 +1,524 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException, Query
2
+ from fastapi.staticfiles import StaticFiles
3
+ from fastapi.responses import StreamingResponse
4
+ from uuid import uuid4
5
+ from typing import Dict, List, Optional
6
+ from dataclasses import asdict
7
+ from enum import Enum
8
+ import logging
9
+ import json
10
+ import asyncio
11
+
12
+ from .domain import VehicleRoutePlan, Location
13
+ from .converters import plan_to_model, model_to_plan
14
+ from .domain import VehicleRoutePlanModel
15
+ from .score_analysis import ConstraintAnalysisDTO, MatchAnalysisDTO
16
+ from .demo_data import generate_demo_data, DemoData
17
+ from .solver import solver_manager, solution_manager
18
+ from .routing import compute_distance_matrix_with_progress, DistanceMatrix
19
+ from pydantic import BaseModel, Field
20
+
21
+
22
+ class RoutingMode(str, Enum):
23
+ """Routing mode for distance calculations."""
24
+ HAVERSINE = "haversine" # Fast, straight-line estimation
25
+ REAL_ROADS = "real_roads" # Slower, uses OSMnx for real road routes
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ app = FastAPI(docs_url='/q/swagger-ui')
30
+
31
+ data_sets: Dict[str, VehicleRoutePlan] = {}
32
+
33
+
34
+ # Request/Response models for recommendation endpoints
35
+ class VehicleRecommendation(BaseModel):
36
+ """Recommendation for assigning a visit to a vehicle at a specific index."""
37
+ vehicle_id: str = Field(..., alias="vehicleId")
38
+ index: int
39
+
40
+ class Config:
41
+ populate_by_name = True
42
+
43
+
44
+ class RecommendedAssignmentResponse(BaseModel):
45
+ """Response from the recommendation API."""
46
+ proposition: VehicleRecommendation
47
+ score_diff: str = Field(..., alias="scoreDiff")
48
+
49
+ class Config:
50
+ populate_by_name = True
51
+
52
+
53
+ class RecommendationRequest(BaseModel):
54
+ """Request for visit assignment recommendations."""
55
+ solution: VehicleRoutePlanModel
56
+ visit_id: str = Field(..., alias="visitId")
57
+
58
+ class Config:
59
+ populate_by_name = True
60
+
61
+
62
+ class ApplyRecommendationRequest(BaseModel):
63
+ """Request to apply a recommendation."""
64
+ solution: VehicleRoutePlanModel
65
+ visit_id: str = Field(..., alias="visitId")
66
+ vehicle_id: str = Field(..., alias="vehicleId")
67
+ index: int
68
+
69
+ class Config:
70
+ populate_by_name = True
71
+
72
+
73
+ def json_to_vehicle_route_plan(json_data: dict) -> VehicleRoutePlan:
74
+ """Convert JSON data to VehicleRoutePlan using the model converters."""
75
+ plan_model = VehicleRoutePlanModel.model_validate(json_data)
76
+ return model_to_plan(plan_model)
77
+
78
+
79
+ @app.get("/demo-data")
80
+ async def get_demo_data():
81
+ """Get available demo data sets."""
82
+ return [demo.name for demo in DemoData]
83
+
84
+ def _extract_all_locations(plan: VehicleRoutePlan) -> list[Location]:
85
+ """Extract all unique locations from a route plan."""
86
+ locations = []
87
+ seen = set()
88
+
89
+ for vehicle in plan.vehicles:
90
+ key = (vehicle.home_location.latitude, vehicle.home_location.longitude)
91
+ if key not in seen:
92
+ locations.append(vehicle.home_location)
93
+ seen.add(key)
94
+
95
+ for visit in plan.visits:
96
+ key = (visit.location.latitude, visit.location.longitude)
97
+ if key not in seen:
98
+ locations.append(visit.location)
99
+ seen.add(key)
100
+
101
+ return locations
102
+
103
+
104
+ def _extract_route_geometries(plan: VehicleRoutePlan) -> Dict[str, List[Optional[str]]]:
105
+ """
106
+ Extract route geometries from the distance matrix for all vehicles.
107
+ Returns empty dict if no distance matrix is available.
108
+ """
109
+ distance_matrix = Location.get_distance_matrix()
110
+ if distance_matrix is None:
111
+ return {}
112
+
113
+ geometries: Dict[str, List[Optional[str]]] = {}
114
+
115
+ for vehicle in plan.vehicles:
116
+ segments: List[Optional[str]] = []
117
+
118
+ if not vehicle.visits:
119
+ geometries[vehicle.id] = segments
120
+ continue
121
+
122
+ # Segment from depot to first visit
123
+ prev_location = vehicle.home_location
124
+ for visit in vehicle.visits:
125
+ geometry = distance_matrix.get_geometry(prev_location, visit.location)
126
+ segments.append(geometry)
127
+ prev_location = visit.location
128
+
129
+ # Segment from last visit back to depot
130
+ geometry = distance_matrix.get_geometry(prev_location, vehicle.home_location)
131
+ segments.append(geometry)
132
+
133
+ geometries[vehicle.id] = segments
134
+
135
+ return geometries
136
+
137
+
138
+ def _initialize_distance_matrix(
139
+ plan: VehicleRoutePlan,
140
+ use_real_roads: bool = False,
141
+ progress_callback=None
142
+ ) -> Optional[DistanceMatrix]:
143
+ """
144
+ Initialize the distance matrix for a route plan.
145
+
146
+ Args:
147
+ plan: The route plan with locations
148
+ use_real_roads: If True, use OSMnx for real road routing (slower)
149
+ If False, use haversine estimation (fast, default)
150
+ progress_callback: Optional callback for progress updates
151
+
152
+ Returns the computed matrix, or None if routing failed.
153
+ """
154
+ locations = _extract_all_locations(plan)
155
+ if not locations:
156
+ return None
157
+
158
+ logger.info(f"Computing distance matrix for {len(locations)} locations (mode: {'real_roads' if use_real_roads else 'haversine'})...")
159
+
160
+ # Compute bounding box from the plan
161
+ bbox = (
162
+ plan.north_east_corner.latitude,
163
+ plan.south_west_corner.latitude,
164
+ plan.north_east_corner.longitude,
165
+ plan.south_west_corner.longitude,
166
+ )
167
+
168
+ try:
169
+ matrix = compute_distance_matrix_with_progress(
170
+ locations,
171
+ bbox=bbox,
172
+ use_osm=use_real_roads,
173
+ progress_callback=progress_callback
174
+ )
175
+ Location.set_distance_matrix(matrix)
176
+ logger.info("Distance matrix computed and set successfully")
177
+ return matrix
178
+ except Exception as e:
179
+ logger.warning(f"Failed to compute distance matrix: {e}")
180
+ return None
181
+
182
+
183
+ @app.get("/demo-data/{demo_name}", response_model=VehicleRoutePlanModel)
184
+ async def get_demo_data_by_name(
185
+ demo_name: str,
186
+ routing: RoutingMode = Query(
187
+ default=RoutingMode.HAVERSINE,
188
+ description="Routing mode: 'haversine' (fast, default) or 'real_roads' (slower, accurate)"
189
+ )
190
+ ) -> VehicleRoutePlanModel:
191
+ """
192
+ Get a specific demo data set.
193
+
194
+ Args:
195
+ demo_name: Name of the demo dataset (PHILADELPHIA, HARTFORT, FIRENZE)
196
+ routing: Routing mode - 'haversine' (fast default) or 'real_roads' (slower, accurate)
197
+
198
+ When routing=real_roads, computes the distance matrix using real road network
199
+ data (OSMnx) for accurate routing. The first call may take 5-15 seconds
200
+ to download the OSM network (cached for subsequent calls).
201
+ """
202
+ try:
203
+ demo_data = DemoData[demo_name]
204
+ domain_plan = generate_demo_data(demo_data)
205
+
206
+ # Initialize distance matrix with selected routing mode
207
+ use_real_roads = routing == RoutingMode.REAL_ROADS
208
+ _initialize_distance_matrix(domain_plan, use_real_roads=use_real_roads)
209
+
210
+ return plan_to_model(domain_plan)
211
+ except KeyError:
212
+ raise HTTPException(status_code=404, detail=f"Demo data '{demo_name}' not found")
213
+
214
+
215
+ # Progress tracking for SSE
216
+ _progress_queues: Dict[str, asyncio.Queue] = {}
217
+
218
+
219
+ @app.get("/demo-data/{demo_name}/stream")
220
+ async def get_demo_data_with_progress(
221
+ demo_name: str,
222
+ routing: RoutingMode = Query(
223
+ default=RoutingMode.HAVERSINE,
224
+ description="Routing mode: 'haversine' (fast, default) or 'real_roads' (slower, accurate)"
225
+ )
226
+ ):
227
+ """
228
+ Get demo data with Server-Sent Events (SSE) progress updates.
229
+
230
+ This endpoint streams progress updates while computing the distance matrix,
231
+ then returns the final solution. Use this when routing=real_roads and you
232
+ want to show progress to the user.
233
+
234
+ Events emitted:
235
+ - progress: {phase, message, percent, detail}
236
+ - complete: {solution: VehicleRoutePlanModel}
237
+ - error: {message}
238
+ """
239
+ async def generate():
240
+ try:
241
+ demo_data = DemoData[demo_name]
242
+ domain_plan = generate_demo_data(demo_data)
243
+
244
+ use_real_roads = routing == RoutingMode.REAL_ROADS
245
+
246
+ if not use_real_roads:
247
+ # Fast path - no progress needed for haversine
248
+ yield f"data: {json.dumps({'event': 'progress', 'phase': 'computing', 'message': 'Computing distances...', 'percent': 50})}\n\n"
249
+ _initialize_distance_matrix(domain_plan, use_real_roads=False)
250
+ yield f"data: {json.dumps({'event': 'progress', 'phase': 'complete', 'message': 'Ready!', 'percent': 100})}\n\n"
251
+ result = plan_to_model(domain_plan)
252
+ # Include geometries (straight lines in haversine mode)
253
+ geometries = _extract_route_geometries(domain_plan)
254
+ yield f"data: {json.dumps({'event': 'complete', 'solution': result.model_dump(by_alias=True), 'geometries': geometries})}\n\n"
255
+ else:
256
+ # Slow path - stream progress for OSMnx
257
+ progress_events = []
258
+
259
+ def progress_callback(phase: str, message: str, percent: int, detail: str = ""):
260
+ progress_events.append({
261
+ 'event': 'progress',
262
+ 'phase': phase,
263
+ 'message': message,
264
+ 'percent': percent,
265
+ 'detail': detail
266
+ })
267
+
268
+ # Run computation in thread pool to not block
269
+ import concurrent.futures
270
+ with concurrent.futures.ThreadPoolExecutor() as executor:
271
+ future = executor.submit(
272
+ _initialize_distance_matrix,
273
+ domain_plan,
274
+ use_real_roads=True,
275
+ progress_callback=progress_callback
276
+ )
277
+
278
+ # Stream progress events while waiting
279
+ last_sent = 0
280
+ while not future.done():
281
+ await asyncio.sleep(0.1)
282
+ while last_sent < len(progress_events):
283
+ yield f"data: {json.dumps(progress_events[last_sent])}\n\n"
284
+ last_sent += 1
285
+
286
+ # Send any remaining progress events
287
+ while last_sent < len(progress_events):
288
+ yield f"data: {json.dumps(progress_events[last_sent])}\n\n"
289
+ last_sent += 1
290
+
291
+ # Get result (will raise if exception occurred)
292
+ future.result()
293
+
294
+ yield f"data: {json.dumps({'event': 'progress', 'phase': 'complete', 'message': 'Ready!', 'percent': 100})}\n\n"
295
+ result = plan_to_model(domain_plan)
296
+
297
+ # Include geometries in response for real roads mode
298
+ geometries = _extract_route_geometries(domain_plan)
299
+ yield f"data: {json.dumps({'event': 'complete', 'solution': result.model_dump(by_alias=True), 'geometries': geometries})}\n\n"
300
+
301
+ except KeyError:
302
+ yield f"data: {json.dumps({'event': 'error', 'message': f'Demo data not found: {demo_name}'})}\n\n"
303
+ except Exception as e:
304
+ logger.exception(f"Error in SSE stream: {e}")
305
+ yield f"data: {json.dumps({'event': 'error', 'message': str(e)})}\n\n"
306
+
307
+ return StreamingResponse(
308
+ generate(),
309
+ media_type="text/event-stream",
310
+ headers={
311
+ "Cache-Control": "no-cache",
312
+ "Connection": "keep-alive",
313
+ "X-Accel-Buffering": "no"
314
+ }
315
+ )
316
+
317
+
318
+ @app.get("/route-plans/{problem_id}", response_model=VehicleRoutePlanModel, response_model_exclude_none=True)
319
+ async def get_route(problem_id: str) -> VehicleRoutePlanModel:
320
+ route = data_sets.get(problem_id)
321
+ if not route:
322
+ raise HTTPException(status_code=404, detail="Route plan not found")
323
+ route.solver_status = solver_manager.get_solver_status(problem_id)
324
+ return plan_to_model(route)
325
+
326
+ @app.post("/route-plans")
327
+ async def solve_route(plan_model: VehicleRoutePlanModel) -> str:
328
+ job_id = str(uuid4())
329
+ # Convert to domain model for solver
330
+ domain_plan = model_to_plan(plan_model)
331
+ data_sets[job_id] = domain_plan
332
+ solver_manager.solve_and_listen(
333
+ job_id,
334
+ domain_plan,
335
+ lambda solution: data_sets.update({job_id: solution})
336
+ )
337
+ return job_id
338
+
339
+ @app.put("/route-plans/analyze")
340
+ async def analyze_route(plan_model: VehicleRoutePlanModel) -> dict:
341
+ domain_plan = model_to_plan(plan_model)
342
+ analysis = solution_manager.analyze(domain_plan)
343
+ constraints = []
344
+ for constraint in getattr(analysis, 'constraint_analyses', []) or []:
345
+ matches = [
346
+ MatchAnalysisDTO(
347
+ name=str(getattr(getattr(match, 'constraint_ref', None), 'constraint_name', "")),
348
+ score=str(getattr(match, 'score', "0hard/0soft")),
349
+ justification=str(getattr(match, 'justification', ""))
350
+ )
351
+ for match in getattr(constraint, 'matches', []) or []
352
+ ]
353
+ constraints.append(ConstraintAnalysisDTO(
354
+ name=str(getattr(constraint, 'constraint_name', "")),
355
+ weight=str(getattr(constraint, 'weight', "0hard/0soft")),
356
+ score=str(getattr(constraint, 'score', "0hard/0soft")),
357
+ matches=matches
358
+ ))
359
+ return {"constraints": [asdict(constraint) for constraint in constraints]}
360
+
361
+ @app.get("/route-plans")
362
+ async def list_route_plans() -> List[str]:
363
+ """List the job IDs of all submitted route plans."""
364
+ return list(data_sets.keys())
365
+
366
+
367
+ @app.get("/route-plans/{problem_id}/status")
368
+ async def get_route_status(problem_id: str) -> dict:
369
+ """Get the route plan status and score for a given job ID."""
370
+ route = data_sets.get(problem_id)
371
+ if not route:
372
+ raise HTTPException(status_code=404, detail="Route plan not found")
373
+ solver_status = solver_manager.get_solver_status(problem_id)
374
+ return {
375
+ "name": route.name,
376
+ "score": str(route.score) if route.score else None,
377
+ "solverStatus": solver_status.name if solver_status else None,
378
+ }
379
+
380
+
381
+ @app.delete("/route-plans/{problem_id}")
382
+ async def stop_solving(problem_id: str) -> VehicleRoutePlanModel:
383
+ """Terminate solving for a given job ID. Returns the best solution so far."""
384
+ solver_manager.terminate_early(problem_id)
385
+ route = data_sets.get(problem_id)
386
+ if not route:
387
+ raise HTTPException(status_code=404, detail="Route plan not found")
388
+ route.solver_status = solver_manager.get_solver_status(problem_id)
389
+ return plan_to_model(route)
390
+
391
+
392
+ @app.post("/route-plans/recommendation")
393
+ async def recommend_assignment(request: RecommendationRequest) -> List[RecommendedAssignmentResponse]:
394
+ """
395
+ Request recommendations for assigning a visit to vehicles.
396
+
397
+ Returns a list of recommended assignments sorted by score impact.
398
+ """
399
+ domain_plan = model_to_plan(request.solution)
400
+
401
+ # Find the visit by ID
402
+ visit = None
403
+ for v in domain_plan.visits:
404
+ if v.id == request.visit_id:
405
+ visit = v
406
+ break
407
+
408
+ if visit is None:
409
+ raise HTTPException(status_code=404, detail=f"Visit {request.visit_id} not found")
410
+
411
+ # Get recommendations using solution_manager
412
+ try:
413
+ recommendations = solution_manager.recommend_assignment(
414
+ domain_plan,
415
+ visit,
416
+ lambda v: VehicleRecommendation(vehicle_id=v.vehicle.id, index=v.vehicle.visits.index(v))
417
+ )
418
+
419
+ # Convert to response format (limit to top 5)
420
+ result = []
421
+ for rec in recommendations[:5]:
422
+ result.append(RecommendedAssignmentResponse(
423
+ proposition=rec.proposition,
424
+ score_diff=str(rec.score_diff) if hasattr(rec, 'score_diff') else "0hard/0soft"
425
+ ))
426
+ return result
427
+ except Exception:
428
+ # If recommend_assignment is not available, return empty list
429
+ return []
430
+
431
+
432
+ @app.post("/route-plans/recommendation/apply")
433
+ async def apply_recommendation(request: ApplyRecommendationRequest) -> VehicleRoutePlanModel:
434
+ """
435
+ Apply a recommendation to assign a visit to a vehicle at a specific index.
436
+
437
+ Returns the updated solution.
438
+ """
439
+ domain_plan = model_to_plan(request.solution)
440
+
441
+ # Find the vehicle by ID
442
+ vehicle = None
443
+ for v in domain_plan.vehicles:
444
+ if v.id == request.vehicle_id:
445
+ vehicle = v
446
+ break
447
+
448
+ if vehicle is None:
449
+ raise HTTPException(status_code=404, detail=f"Vehicle {request.vehicle_id} not found")
450
+
451
+ # Find the visit by ID
452
+ visit = None
453
+ for v in domain_plan.visits:
454
+ if v.id == request.visit_id:
455
+ visit = v
456
+ break
457
+
458
+ if visit is None:
459
+ raise HTTPException(status_code=404, detail=f"Visit {request.visit_id} not found")
460
+
461
+ # Insert visit at the specified index
462
+ vehicle.visits.insert(request.index, visit)
463
+
464
+ # Update the solution to recalculate shadow variables
465
+ solution_manager.update(domain_plan)
466
+
467
+ return plan_to_model(domain_plan)
468
+
469
+
470
+ class RouteGeometryResponse(BaseModel):
471
+ """Response containing encoded polyline geometries for all vehicle routes."""
472
+ geometries: Dict[str, List[Optional[str]]]
473
+
474
+
475
+ @app.get("/route-plans/{problem_id}/geometry", response_model=RouteGeometryResponse)
476
+ async def get_route_geometry(problem_id: str) -> RouteGeometryResponse:
477
+ """
478
+ Get route geometries for all vehicle routes in a problem.
479
+
480
+ Returns encoded polylines (Google polyline format) for each route segment.
481
+ Each vehicle's route is represented as a list of encoded polylines:
482
+ - First segment: depot -> first visit
483
+ - Middle segments: visit -> visit
484
+ - Last segment: last visit -> depot
485
+
486
+ These can be decoded on the frontend to display actual road routes
487
+ instead of straight lines.
488
+ """
489
+ route = data_sets.get(problem_id)
490
+ if not route:
491
+ raise HTTPException(status_code=404, detail="Route plan not found")
492
+
493
+ distance_matrix = Location.get_distance_matrix()
494
+ if distance_matrix is None:
495
+ # No distance matrix available - return empty geometries
496
+ return RouteGeometryResponse(geometries={})
497
+
498
+ geometries: Dict[str, List[Optional[str]]] = {}
499
+
500
+ for vehicle in route.vehicles:
501
+ segments: List[Optional[str]] = []
502
+
503
+ if not vehicle.visits:
504
+ # No visits assigned to this vehicle
505
+ geometries[vehicle.id] = segments
506
+ continue
507
+
508
+ # Segment from depot to first visit
509
+ prev_location = vehicle.home_location
510
+ for visit in vehicle.visits:
511
+ geometry = distance_matrix.get_geometry(prev_location, visit.location)
512
+ segments.append(geometry)
513
+ prev_location = visit.location
514
+
515
+ # Segment from last visit back to depot
516
+ geometry = distance_matrix.get_geometry(prev_location, vehicle.home_location)
517
+ segments.append(geometry)
518
+
519
+ geometries[vehicle.id] = segments
520
+
521
+ return RouteGeometryResponse(geometries=geometries)
522
+
523
+
524
+ app.mount("/", StaticFiles(directory="static", html=True), name="static")
src/vehicle_routing/routing.py ADDED
@@ -0,0 +1,622 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Real-world routing service using OSMnx for road network data.
3
+
4
+ This module provides:
5
+ - OSMnxRoutingService: Downloads OSM network, caches locally, computes routes
6
+ - DistanceMatrix: Precomputes all pairwise routes with times and geometries
7
+ - Haversine fallback when OSMnx is unavailable
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import math
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Optional
17
+
18
+ import polyline
19
+
20
+ if TYPE_CHECKING:
21
+ from .domain import Location
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Cache directory for OSM network data
26
+ CACHE_DIR = Path(__file__).parent.parent.parent / ".osm_cache"
27
+
28
+
29
+ @dataclass
30
+ class RouteResult:
31
+ """Result from a routing query."""
32
+
33
+ duration_seconds: int
34
+ distance_meters: int
35
+ geometry: Optional[str] = None # Encoded polyline
36
+
37
+
38
+ @dataclass
39
+ class DistanceMatrix:
40
+ """
41
+ Precomputed distance/time matrix for all location pairs.
42
+
43
+ Stores RouteResult for each (origin, destination) pair,
44
+ enabling O(1) lookup during solver execution.
45
+ """
46
+
47
+ _matrix: dict[tuple[tuple[float, float], tuple[float, float]], RouteResult] = field(
48
+ default_factory=dict
49
+ )
50
+
51
+ def _key(
52
+ self, origin: "Location", destination: "Location"
53
+ ) -> tuple[tuple[float, float], tuple[float, float]]:
54
+ """Create hashable key from two locations."""
55
+ return (
56
+ (origin.latitude, origin.longitude),
57
+ (destination.latitude, destination.longitude),
58
+ )
59
+
60
+ def set_route(
61
+ self, origin: "Location", destination: "Location", result: RouteResult
62
+ ) -> None:
63
+ """Store a route result in the matrix."""
64
+ self._matrix[self._key(origin, destination)] = result
65
+
66
+ def get_route(
67
+ self, origin: "Location", destination: "Location"
68
+ ) -> Optional[RouteResult]:
69
+ """Get a route result from the matrix."""
70
+ return self._matrix.get(self._key(origin, destination))
71
+
72
+ def get_driving_time(self, origin: "Location", destination: "Location") -> int:
73
+ """Get driving time in seconds between two locations."""
74
+ result = self.get_route(origin, destination)
75
+ if result is None:
76
+ # Fallback to haversine if not in matrix
77
+ return _haversine_driving_time(origin, destination)
78
+ return result.duration_seconds
79
+
80
+ def get_geometry(
81
+ self, origin: "Location", destination: "Location"
82
+ ) -> Optional[str]:
83
+ """Get encoded polyline geometry for a route segment."""
84
+ result = self.get_route(origin, destination)
85
+ return result.geometry if result else None
86
+
87
+
88
+ def _haversine_driving_time(origin: "Location", destination: "Location") -> int:
89
+ """
90
+ Calculate driving time using haversine formula (fallback).
91
+
92
+ Uses 50 km/h average speed assumption.
93
+ """
94
+ if (
95
+ origin.latitude == destination.latitude
96
+ and origin.longitude == destination.longitude
97
+ ):
98
+ return 0
99
+
100
+ EARTH_RADIUS_M = 6371000
101
+ AVERAGE_SPEED_KMPH = 50
102
+
103
+ lat1 = math.radians(origin.latitude)
104
+ lon1 = math.radians(origin.longitude)
105
+ lat2 = math.radians(destination.latitude)
106
+ lon2 = math.radians(destination.longitude)
107
+
108
+ # Haversine formula
109
+ dlat = lat2 - lat1
110
+ dlon = lon2 - lon1
111
+ a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
112
+ c = 2 * math.asin(math.sqrt(a))
113
+ distance_meters = EARTH_RADIUS_M * c
114
+
115
+ # Convert to driving time
116
+ return round(distance_meters / AVERAGE_SPEED_KMPH * 3.6)
117
+
118
+
119
+ class OSMnxRoutingService:
120
+ """
121
+ Routing service using OSMnx for real road network data.
122
+
123
+ Downloads the OSM network for a given bounding box, caches it locally,
124
+ and computes shortest paths using NetworkX.
125
+ """
126
+
127
+ def __init__(self, cache_dir: Path = CACHE_DIR):
128
+ self.cache_dir = cache_dir
129
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
130
+ self._graph = None
131
+ self._graph_bbox = None
132
+
133
+ def _get_cache_path(
134
+ self, north: float, south: float, east: float, west: float
135
+ ) -> Path:
136
+ """Generate cache file path for a bounding box."""
137
+ # Round to 2 decimal places for cache key
138
+ key = f"osm_{north:.2f}_{south:.2f}_{east:.2f}_{west:.2f}.graphml"
139
+ return self.cache_dir / key
140
+
141
+ def load_network(
142
+ self, north: float, south: float, east: float, west: float, padding: float = 0.01
143
+ ) -> bool:
144
+ """
145
+ Load OSM road network for the given bounding box.
146
+
147
+ Args:
148
+ north, south, east, west: Bounding box coordinates
149
+ padding: Extra padding around the bbox (in degrees)
150
+
151
+ Returns:
152
+ True if network loaded successfully, False otherwise
153
+ """
154
+ try:
155
+ import osmnx as ox
156
+
157
+ # Add padding to ensure we have roads outside the strict bbox
158
+ north += padding
159
+ south -= padding
160
+ east += padding
161
+ west -= padding
162
+
163
+ cache_path = self._get_cache_path(north, south, east, west)
164
+
165
+ if cache_path.exists() and cache_path.stat().st_size > 0:
166
+ logger.info(f"Loading cached OSM network from {cache_path}")
167
+ self._graph = ox.load_graphml(cache_path)
168
+
169
+ # Check if the cached graph already has travel_time
170
+ # (we now save enriched graphs)
171
+ sample_edge = next(iter(self._graph.edges(data=True)), None)
172
+ has_travel_time = sample_edge and "travel_time" in sample_edge[2]
173
+
174
+ if not has_travel_time:
175
+ logger.info("Adding edge speeds and travel times to cached graph...")
176
+ self._graph = ox.add_edge_speeds(self._graph)
177
+ self._graph = ox.add_edge_travel_times(self._graph)
178
+ # Re-save with travel times included
179
+ ox.save_graphml(self._graph, cache_path)
180
+ logger.info("Updated cache with travel times")
181
+ else:
182
+ logger.info(
183
+ f"Downloading OSM network for bbox: N={north:.4f}, S={south:.4f}, E={east:.4f}, W={west:.4f}"
184
+ )
185
+ # OSMnx 2.x uses bbox as tuple: (left, bottom, right, top) = (west, south, east, north)
186
+ bbox_tuple = (west, south, east, north)
187
+ self._graph = ox.graph_from_bbox(
188
+ bbox=bbox_tuple,
189
+ network_type="drive",
190
+ simplify=True,
191
+ )
192
+
193
+ # Add edge speeds and travel times BEFORE caching
194
+ logger.info("Computing edge speeds and travel times...")
195
+ self._graph = ox.add_edge_speeds(self._graph)
196
+ self._graph = ox.add_edge_travel_times(self._graph)
197
+
198
+ # Save enriched graph to cache
199
+ ox.save_graphml(self._graph, cache_path)
200
+ logger.info(f"Saved enriched OSM network to cache: {cache_path}")
201
+
202
+ self._graph_bbox = (north, south, east, west)
203
+ logger.info(
204
+ f"OSM network loaded: {self._graph.number_of_nodes()} nodes, "
205
+ f"{self._graph.number_of_edges()} edges"
206
+ )
207
+ return True
208
+
209
+ except ImportError:
210
+ logger.warning("OSMnx not installed, falling back to haversine")
211
+ return False
212
+ except Exception as e:
213
+ logger.warning(f"Failed to load OSM network: {e}, falling back to haversine")
214
+ return False
215
+
216
+ def get_nearest_node(self, location: "Location") -> Optional[int]:
217
+ """Get the nearest graph node for a location."""
218
+ if self._graph is None:
219
+ return None
220
+ try:
221
+ import osmnx as ox
222
+ return ox.nearest_nodes(self._graph, location.longitude, location.latitude)
223
+ except Exception:
224
+ return None
225
+
226
+ def compute_all_routes(
227
+ self,
228
+ locations: list["Location"],
229
+ progress_callback=None
230
+ ) -> dict[tuple[int, int], RouteResult]:
231
+ """
232
+ Compute all pairwise routes efficiently using batch shortest paths.
233
+
234
+ Returns a dict mapping (origin_idx, dest_idx) to RouteResult.
235
+ """
236
+ import networkx as nx
237
+
238
+ if self._graph is None:
239
+ return {}
240
+
241
+ results = {}
242
+ n = len(locations)
243
+
244
+ # Map locations to nearest nodes (batch operation)
245
+ if progress_callback:
246
+ progress_callback("routes", "Finding nearest road nodes...", 30, f"{n} locations")
247
+
248
+ nodes = []
249
+ for loc in locations:
250
+ node = self.get_nearest_node(loc)
251
+ nodes.append(node)
252
+
253
+ # Compute shortest paths from each origin to ALL destinations at once
254
+ # This is MUCH faster than individual shortest_path calls
255
+ total_origins = sum(1 for node in nodes if node is not None)
256
+ processed = 0
257
+
258
+ for i, origin_node in enumerate(nodes):
259
+ if origin_node is None:
260
+ continue
261
+
262
+ # Compute shortest paths from this origin to all nodes at once
263
+ # Using Dijkstra's algorithm with single-source
264
+ try:
265
+ lengths, paths = nx.single_source_dijkstra(
266
+ self._graph, origin_node, weight="travel_time"
267
+ )
268
+ except nx.NetworkXError:
269
+ continue
270
+
271
+ for j, dest_node in enumerate(nodes):
272
+ if dest_node is None:
273
+ continue
274
+
275
+ origin_loc = locations[i]
276
+ dest_loc = locations[j]
277
+
278
+ if i == j or origin_node == dest_node:
279
+ # Same location
280
+ results[(i, j)] = RouteResult(
281
+ duration_seconds=0,
282
+ distance_meters=0,
283
+ geometry=polyline.encode(
284
+ [(origin_loc.latitude, origin_loc.longitude)], precision=5
285
+ ),
286
+ )
287
+ elif dest_node in paths:
288
+ path = paths[dest_node]
289
+ travel_time = lengths[dest_node]
290
+
291
+ # Calculate distance and extract geometry
292
+ total_distance = 0
293
+ coordinates = []
294
+
295
+ for k in range(len(path) - 1):
296
+ u, v = path[k], path[k + 1]
297
+ edge_data = self._graph.get_edge_data(u, v)
298
+ if edge_data:
299
+ edge = edge_data[0] if isinstance(edge_data, dict) else edge_data
300
+ total_distance += edge.get("length", 0)
301
+
302
+ for node in path:
303
+ node_data = self._graph.nodes[node]
304
+ coordinates.append((node_data["y"], node_data["x"]))
305
+
306
+ results[(i, j)] = RouteResult(
307
+ duration_seconds=round(travel_time),
308
+ distance_meters=round(total_distance),
309
+ geometry=polyline.encode(coordinates, precision=5),
310
+ )
311
+
312
+ processed += 1
313
+ if progress_callback and processed % max(1, total_origins // 10) == 0:
314
+ percent = 30 + int((processed / total_origins) * 65)
315
+ progress_callback(
316
+ "routes",
317
+ "Computing routes...",
318
+ percent,
319
+ f"{processed}/{total_origins} origins processed"
320
+ )
321
+
322
+ return results
323
+
324
+ def get_route(
325
+ self, origin: "Location", destination: "Location"
326
+ ) -> Optional[RouteResult]:
327
+ """
328
+ Compute route between two locations.
329
+
330
+ Returns:
331
+ RouteResult with duration, distance, and geometry, or None if routing fails
332
+ """
333
+ if self._graph is None:
334
+ return None
335
+
336
+ try:
337
+ import osmnx as ox
338
+
339
+ # Find nearest nodes to origin and destination
340
+ origin_node = ox.nearest_nodes(
341
+ self._graph, origin.longitude, origin.latitude
342
+ )
343
+ dest_node = ox.nearest_nodes(
344
+ self._graph, destination.longitude, destination.latitude
345
+ )
346
+
347
+ # Same node means same location (or very close)
348
+ if origin_node == dest_node:
349
+ return RouteResult(
350
+ duration_seconds=0,
351
+ distance_meters=0,
352
+ geometry=polyline.encode(
353
+ [(origin.latitude, origin.longitude)], precision=5
354
+ ),
355
+ )
356
+
357
+ # Compute shortest path by travel time
358
+ route = ox.shortest_path(
359
+ self._graph, origin_node, dest_node, weight="travel_time"
360
+ )
361
+
362
+ if route is None:
363
+ logger.warning(
364
+ f"No route found between {origin} and {destination}"
365
+ )
366
+ return None
367
+
368
+ # Extract route attributes
369
+ total_time = 0
370
+ total_distance = 0
371
+ coordinates = []
372
+
373
+ for i in range(len(route) - 1):
374
+ u, v = route[i], route[i + 1]
375
+ edge_data = self._graph.get_edge_data(u, v)
376
+ if edge_data:
377
+ # Get the first edge if multiple exist
378
+ edge = edge_data[0] if isinstance(edge_data, dict) else edge_data
379
+ total_time += edge.get("travel_time", 0)
380
+ total_distance += edge.get("length", 0)
381
+
382
+ # Get node coordinates for geometry
383
+ for node in route:
384
+ node_data = self._graph.nodes[node]
385
+ coordinates.append((node_data["y"], node_data["x"]))
386
+
387
+ # Encode geometry as polyline
388
+ encoded_geometry = polyline.encode(coordinates, precision=5)
389
+
390
+ return RouteResult(
391
+ duration_seconds=round(total_time),
392
+ distance_meters=round(total_distance),
393
+ geometry=encoded_geometry,
394
+ )
395
+
396
+ except Exception as e:
397
+ logger.warning(f"Routing failed: {e}")
398
+ return None
399
+
400
+
401
+ def compute_distance_matrix(
402
+ locations: list["Location"],
403
+ routing_service: Optional[OSMnxRoutingService] = None,
404
+ bbox: Optional[tuple[float, float, float, float]] = None,
405
+ ) -> DistanceMatrix:
406
+ """
407
+ Compute distance matrix for all location pairs.
408
+
409
+ Args:
410
+ locations: List of Location objects
411
+ routing_service: Optional pre-configured routing service
412
+ bbox: Optional (north, south, east, west) tuple for network download
413
+
414
+ Returns:
415
+ DistanceMatrix with precomputed routes
416
+ """
417
+ return compute_distance_matrix_with_progress(
418
+ locations, routing_service, bbox, use_osm=True, progress_callback=None
419
+ )
420
+
421
+
422
+ def compute_distance_matrix_with_progress(
423
+ locations: list["Location"],
424
+ bbox: Optional[tuple[float, float, float, float]] = None,
425
+ use_osm: bool = True,
426
+ progress_callback=None,
427
+ routing_service: Optional[OSMnxRoutingService] = None,
428
+ ) -> DistanceMatrix:
429
+ """
430
+ Compute distance matrix for all location pairs with progress reporting.
431
+
432
+ Args:
433
+ locations: List of Location objects
434
+ bbox: Optional (north, south, east, west) tuple for network download
435
+ use_osm: If True, try to use OSMnx for real routing. If False, use haversine.
436
+ progress_callback: Optional callback(phase, message, percent, detail) for progress updates
437
+ routing_service: Optional pre-configured routing service
438
+
439
+ Returns:
440
+ DistanceMatrix with precomputed routes
441
+ """
442
+ matrix = DistanceMatrix()
443
+
444
+ if not locations:
445
+ return matrix
446
+
447
+ def report_progress(phase: str, message: str, percent: int, detail: str = ""):
448
+ if progress_callback:
449
+ progress_callback(phase, message, percent, detail)
450
+ logger.info(f"[{phase}] {message} ({percent}%) {detail}")
451
+
452
+ # Compute bounding box from locations if not provided
453
+ if bbox is None:
454
+ lats = [loc.latitude for loc in locations]
455
+ lons = [loc.longitude for loc in locations]
456
+ bbox = (max(lats), min(lats), max(lons), min(lons))
457
+
458
+ osm_loaded = False
459
+
460
+ if use_osm:
461
+ # Create routing service if not provided
462
+ if routing_service is None:
463
+ routing_service = OSMnxRoutingService()
464
+
465
+ report_progress("network", "Checking for cached road network...", 5)
466
+
467
+ # Check if cached
468
+ north, south, east, west = bbox
469
+ north += 0.01 # padding
470
+ south -= 0.01
471
+ east += 0.01
472
+ west -= 0.01
473
+
474
+ cache_path = routing_service._get_cache_path(north, south, east, west)
475
+ is_cached = cache_path.exists()
476
+
477
+ if is_cached:
478
+ report_progress("network", "Loading cached road network...", 10, str(cache_path.name))
479
+ else:
480
+ report_progress(
481
+ "network",
482
+ "Downloading OpenStreetMap road network...",
483
+ 10,
484
+ f"Area: {abs(north-south):.2f}° × {abs(east-west):.2f}°"
485
+ )
486
+
487
+ # Try to load OSM network
488
+ osm_loaded = routing_service.load_network(
489
+ north=bbox[0], south=bbox[1], east=bbox[2], west=bbox[3]
490
+ )
491
+
492
+ if osm_loaded:
493
+ node_count = routing_service._graph.number_of_nodes()
494
+ edge_count = routing_service._graph.number_of_edges()
495
+ report_progress(
496
+ "network",
497
+ "Road network loaded",
498
+ 25,
499
+ f"{node_count:,} nodes, {edge_count:,} edges"
500
+ )
501
+ else:
502
+ report_progress("network", "OSMnx unavailable, using haversine", 25)
503
+ else:
504
+ report_progress("network", "Using fast haversine mode", 25)
505
+
506
+ # Compute all pairwise routes
507
+ total_pairs = len(locations) * len(locations)
508
+
509
+ if osm_loaded and routing_service:
510
+ # Use batch routing for OSMnx (MUCH faster than individual calls)
511
+ report_progress(
512
+ "routes",
513
+ f"Computing {total_pairs:,} routes (batch mode)...",
514
+ 30,
515
+ f"{len(locations)} locations"
516
+ )
517
+
518
+ batch_results = routing_service.compute_all_routes(
519
+ locations,
520
+ progress_callback=report_progress
521
+ )
522
+
523
+ # Transfer batch results to matrix, with haversine fallback for missing routes
524
+ computed = 0
525
+ for i, origin in enumerate(locations):
526
+ for j, destination in enumerate(locations):
527
+ if (i, j) in batch_results:
528
+ matrix.set_route(origin, destination, batch_results[(i, j)])
529
+ else:
530
+ # Fallback to haversine for routes not found
531
+ matrix.set_route(
532
+ origin,
533
+ destination,
534
+ RouteResult(
535
+ duration_seconds=_haversine_driving_time(origin, destination),
536
+ distance_meters=_haversine_distance_meters(origin, destination),
537
+ geometry=_straight_line_geometry(origin, destination),
538
+ ),
539
+ )
540
+ computed += 1
541
+
542
+ report_progress("complete", "Distance matrix ready", 100, f"{computed:,} routes computed")
543
+ else:
544
+ # Use haversine fallback for all routes
545
+ report_progress(
546
+ "routes",
547
+ f"Computing {total_pairs:,} route pairs...",
548
+ 30,
549
+ f"{len(locations)} locations"
550
+ )
551
+
552
+ computed = 0
553
+ for origin in locations:
554
+ for destination in locations:
555
+ if origin is destination:
556
+ matrix.set_route(
557
+ origin,
558
+ destination,
559
+ RouteResult(
560
+ duration_seconds=0,
561
+ distance_meters=0,
562
+ geometry=polyline.encode(
563
+ [(origin.latitude, origin.longitude)], precision=5
564
+ ),
565
+ ),
566
+ )
567
+ else:
568
+ matrix.set_route(
569
+ origin,
570
+ destination,
571
+ RouteResult(
572
+ duration_seconds=_haversine_driving_time(origin, destination),
573
+ distance_meters=_haversine_distance_meters(origin, destination),
574
+ geometry=_straight_line_geometry(origin, destination),
575
+ ),
576
+ )
577
+ computed += 1
578
+
579
+ # Report progress every 5%
580
+ if total_pairs > 0 and computed % max(1, total_pairs // 20) == 0:
581
+ percent_complete = int(30 + (computed / total_pairs) * 65)
582
+ report_progress(
583
+ "routes",
584
+ f"Computing routes...",
585
+ percent_complete,
586
+ f"{computed:,}/{total_pairs:,} pairs"
587
+ )
588
+
589
+ report_progress("complete", "Distance matrix ready", 100, f"{computed:,} routes computed")
590
+
591
+ return matrix
592
+
593
+
594
+ def _haversine_distance_meters(origin: "Location", destination: "Location") -> int:
595
+ """Calculate haversine distance in meters."""
596
+ if (
597
+ origin.latitude == destination.latitude
598
+ and origin.longitude == destination.longitude
599
+ ):
600
+ return 0
601
+
602
+ EARTH_RADIUS_M = 6371000
603
+
604
+ lat1 = math.radians(origin.latitude)
605
+ lon1 = math.radians(origin.longitude)
606
+ lat2 = math.radians(destination.latitude)
607
+ lon2 = math.radians(destination.longitude)
608
+
609
+ dlat = lat2 - lat1
610
+ dlon = lon2 - lon1
611
+ a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
612
+ c = 2 * math.asin(math.sqrt(a))
613
+
614
+ return round(EARTH_RADIUS_M * c)
615
+
616
+
617
+ def _straight_line_geometry(origin: "Location", destination: "Location") -> str:
618
+ """Generate a straight-line encoded polyline between two points."""
619
+ return polyline.encode(
620
+ [(origin.latitude, origin.longitude), (destination.latitude, destination.longitude)],
621
+ precision=5,
622
+ )
src/vehicle_routing/score_analysis.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from typing import Annotated
3
+
4
+ from solverforge_legacy.solver.score import HardSoftScore
5
+ from .json_serialization import ScoreSerializer
6
+
7
+
8
+ @dataclass
9
+ class MatchAnalysisDTO:
10
+ name: str
11
+ score: Annotated[HardSoftScore, ScoreSerializer]
12
+ justification: object
13
+
14
+
15
+ @dataclass
16
+ class ConstraintAnalysisDTO:
17
+ name: str
18
+ weight: Annotated[HardSoftScore, ScoreSerializer]
19
+ matches: list[MatchAnalysisDTO]
20
+ score: Annotated[HardSoftScore, ScoreSerializer]
src/vehicle_routing/solver.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver import SolverManager, SolutionManager
2
+ from solverforge_legacy.solver.config import (
3
+ SolverConfig,
4
+ ScoreDirectorFactoryConfig,
5
+ TerminationConfig,
6
+ Duration,
7
+ )
8
+
9
+ from .domain import Vehicle, VehicleRoutePlan, Visit
10
+ from .constraints import define_constraints
11
+
12
+
13
+ solver_config = SolverConfig(
14
+ solution_class=VehicleRoutePlan,
15
+ entity_class_list=[Vehicle, Visit],
16
+ score_director_factory_config=ScoreDirectorFactoryConfig(
17
+ constraint_provider_function=define_constraints
18
+ ),
19
+ termination_config=TerminationConfig(spent_limit=Duration(seconds=30)),
20
+ )
21
+
22
+ solver_manager = SolverManager.create(solver_config)
23
+ solution_manager = SolutionManager.create(solver_manager)
static/app.js ADDED
@@ -0,0 +1,1607 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let autoRefreshIntervalId = null;
2
+ let initialized = false;
3
+ let optimizing = false;
4
+ let demoDataId = null;
5
+ let scheduleId = null;
6
+ let loadedRoutePlan = null;
7
+ let newVisit = null;
8
+ let visitMarker = null;
9
+ let routeGeometries = null; // Cache for encoded polyline geometries
10
+ let useRealRoads = false; // Routing mode toggle state
11
+ const solveButton = $("#solveButton");
12
+ const stopSolvingButton = $("#stopSolvingButton");
13
+ const vehiclesTable = $("#vehicles");
14
+ const analyzeButton = $("#analyzeButton");
15
+
16
+ /**
17
+ * Decode an encoded polyline string into an array of [lat, lng] coordinates.
18
+ * This is the Google polyline encoding algorithm.
19
+ * @param {string} encoded - The encoded polyline string
20
+ * @returns {Array<Array<number>>} Array of [lat, lng] coordinate pairs
21
+ */
22
+ function decodePolyline(encoded) {
23
+ if (!encoded) return [];
24
+
25
+ const points = [];
26
+ let index = 0;
27
+ let lat = 0;
28
+ let lng = 0;
29
+
30
+ while (index < encoded.length) {
31
+ // Decode latitude
32
+ let shift = 0;
33
+ let result = 0;
34
+ let byte;
35
+ do {
36
+ byte = encoded.charCodeAt(index++) - 63;
37
+ result |= (byte & 0x1f) << shift;
38
+ shift += 5;
39
+ } while (byte >= 0x20);
40
+ const dlat = (result & 1) ? ~(result >> 1) : (result >> 1);
41
+ lat += dlat;
42
+
43
+ // Decode longitude
44
+ shift = 0;
45
+ result = 0;
46
+ do {
47
+ byte = encoded.charCodeAt(index++) - 63;
48
+ result |= (byte & 0x1f) << shift;
49
+ shift += 5;
50
+ } while (byte >= 0x20);
51
+ const dlng = (result & 1) ? ~(result >> 1) : (result >> 1);
52
+ lng += dlng;
53
+
54
+ // Polyline encoding uses precision of 5 decimal places
55
+ points.push([lat / 1e5, lng / 1e5]);
56
+ }
57
+
58
+ return points;
59
+ }
60
+
61
+ /**
62
+ * Fetch route geometries for the current schedule from the backend.
63
+ * @returns {Promise<Object|null>} The geometries object or null if unavailable
64
+ */
65
+ async function fetchRouteGeometries() {
66
+ if (!scheduleId) return null;
67
+
68
+ try {
69
+ const response = await fetch(`/route-plans/${scheduleId}/geometry`);
70
+ if (response.ok) {
71
+ const data = await response.json();
72
+ return data.geometries || null;
73
+ }
74
+ } catch (e) {
75
+ console.warn('Could not fetch route geometries:', e);
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /*************************************** Loading Overlay Functions **************************************/
81
+
82
+ function showLoadingOverlay(title = "Loading Demo Data", message = "Initializing...") {
83
+ $("#loadingTitle").text(title);
84
+ $("#loadingMessage").text(message);
85
+ $("#loadingProgress").css("width", "0%");
86
+ $("#loadingDetail").text("");
87
+ $("#loadingOverlay").removeClass("hidden");
88
+ }
89
+
90
+ function hideLoadingOverlay() {
91
+ $("#loadingOverlay").addClass("hidden");
92
+ }
93
+
94
+ function updateLoadingProgress(message, percent, detail = "") {
95
+ $("#loadingMessage").text(message);
96
+ $("#loadingProgress").css("width", `${percent}%`);
97
+ $("#loadingDetail").text(detail);
98
+ }
99
+
100
+ /**
101
+ * Load demo data with progress updates via Server-Sent Events.
102
+ * Used when Real Roads mode is enabled.
103
+ */
104
+ function loadDemoDataWithProgress(demoId) {
105
+ return new Promise((resolve, reject) => {
106
+ const routingMode = useRealRoads ? "real_roads" : "haversine";
107
+ const url = `/demo-data/${demoId}/stream?routing=${routingMode}`;
108
+
109
+ showLoadingOverlay(
110
+ useRealRoads ? "Loading Real Road Data" : "Loading Demo Data",
111
+ "Connecting..."
112
+ );
113
+
114
+ const eventSource = new EventSource(url);
115
+ let solution = null;
116
+
117
+ eventSource.onmessage = function(event) {
118
+ try {
119
+ const data = JSON.parse(event.data);
120
+
121
+ if (data.event === "progress") {
122
+ let statusIcon = "";
123
+ if (data.phase === "network") {
124
+ statusIcon = '<i class="fas fa-download me-2"></i>';
125
+ } else if (data.phase === "routes") {
126
+ statusIcon = '<i class="fas fa-route me-2"></i>';
127
+ } else if (data.phase === "complete") {
128
+ statusIcon = '<i class="fas fa-check-circle me-2 text-success"></i>';
129
+ }
130
+ updateLoadingProgress(data.message, data.percent, data.detail || "");
131
+ } else if (data.event === "complete") {
132
+ solution = data.solution;
133
+ // Store geometries from the response if available
134
+ if (data.geometries) {
135
+ routeGeometries = data.geometries;
136
+ }
137
+ eventSource.close();
138
+ hideLoadingOverlay();
139
+ resolve(solution);
140
+ } else if (data.event === "error") {
141
+ eventSource.close();
142
+ hideLoadingOverlay();
143
+ reject(new Error(data.message));
144
+ }
145
+ } catch (e) {
146
+ console.error("Error parsing SSE event:", e);
147
+ }
148
+ };
149
+
150
+ eventSource.onerror = function(error) {
151
+ eventSource.close();
152
+ hideLoadingOverlay();
153
+ reject(new Error("Connection lost while loading data"));
154
+ };
155
+ });
156
+ }
157
+
158
+ /*************************************** Map constants and variable definitions **************************************/
159
+
160
+ const homeLocationMarkerByIdMap = new Map();
161
+ const visitMarkerByIdMap = new Map();
162
+
163
+ const map = L.map("map", { doubleClickZoom: false }).setView(
164
+ [51.505, -0.09],
165
+ 13,
166
+ );
167
+ const visitGroup = L.layerGroup().addTo(map);
168
+ const homeLocationGroup = L.layerGroup().addTo(map);
169
+ const routeGroup = L.layerGroup().addTo(map);
170
+
171
+ /************************************ Time line constants and variable definitions ************************************/
172
+
173
+ let byVehicleTimeline;
174
+ let byVisitTimeline;
175
+ const byVehicleGroupData = new vis.DataSet();
176
+ const byVehicleItemData = new vis.DataSet();
177
+ const byVisitGroupData = new vis.DataSet();
178
+ const byVisitItemData = new vis.DataSet();
179
+
180
+ const byVehicleTimelineOptions = {
181
+ timeAxis: { scale: "hour" },
182
+ orientation: { axis: "top" },
183
+ xss: { disabled: true }, // Items are XSS safe through JQuery
184
+ stack: false,
185
+ stackSubgroups: false,
186
+ zoomMin: 1000 * 60 * 60, // A single hour in milliseconds
187
+ zoomMax: 1000 * 60 * 60 * 24, // A single day in milliseconds
188
+ };
189
+
190
+ const byVisitTimelineOptions = {
191
+ timeAxis: { scale: "hour" },
192
+ orientation: { axis: "top" },
193
+ verticalScroll: true,
194
+ xss: { disabled: true }, // Items are XSS safe through JQuery
195
+ stack: false,
196
+ stackSubgroups: false,
197
+ zoomMin: 1000 * 60 * 60, // A single hour in milliseconds
198
+ zoomMax: 1000 * 60 * 60 * 24, // A single day in milliseconds
199
+ };
200
+
201
+ /************************************ Initialize ************************************/
202
+
203
+ // Vehicle management state
204
+ let addingVehicleMode = false;
205
+ let pickingVehicleLocation = false;
206
+ let tempVehicleMarker = null;
207
+ let vehicleDeparturePicker = null;
208
+
209
+ // Route highlighting state
210
+ let highlightedVehicleId = null;
211
+ let routeNumberMarkers = []; // Markers showing 1, 2, 3... on route stops
212
+
213
+
214
+ $(document).ready(function () {
215
+ replaceQuickstartSolverForgeAutoHeaderFooter();
216
+
217
+ // Initialize timelines after DOM is ready with a small delay to ensure Bootstrap tabs are rendered
218
+ setTimeout(function () {
219
+ const byVehiclePanel = document.getElementById("byVehiclePanel");
220
+ const byVisitPanel = document.getElementById("byVisitPanel");
221
+
222
+ if (byVehiclePanel) {
223
+ byVehicleTimeline = new vis.Timeline(
224
+ byVehiclePanel,
225
+ byVehicleItemData,
226
+ byVehicleGroupData,
227
+ byVehicleTimelineOptions,
228
+ );
229
+ }
230
+
231
+ if (byVisitPanel) {
232
+ byVisitTimeline = new vis.Timeline(
233
+ byVisitPanel,
234
+ byVisitItemData,
235
+ byVisitGroupData,
236
+ byVisitTimelineOptions,
237
+ );
238
+ }
239
+ }, 100);
240
+
241
+ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
242
+ maxZoom: 19,
243
+ attribution:
244
+ '&copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors',
245
+ }).addTo(map);
246
+
247
+ solveButton.click(solve);
248
+ stopSolvingButton.click(stopSolving);
249
+ analyzeButton.click(analyze);
250
+ refreshSolvingButtons(false);
251
+
252
+ // HACK to allow vis-timeline to work within Bootstrap tabs
253
+ $("#byVehicleTab").on("shown.bs.tab", function (event) {
254
+ if (byVehicleTimeline) {
255
+ byVehicleTimeline.redraw();
256
+ }
257
+ });
258
+ $("#byVisitTab").on("shown.bs.tab", function (event) {
259
+ if (byVisitTimeline) {
260
+ byVisitTimeline.redraw();
261
+ }
262
+ });
263
+
264
+ // Map click handler - context aware
265
+ map.on("click", function (e) {
266
+ if (addingVehicleMode) {
267
+ // Set vehicle home location
268
+ setVehicleHomeLocation(e.latlng.lat, e.latlng.lng);
269
+ } else if (!optimizing) {
270
+ // Add new visit
271
+ visitMarker = L.circleMarker(e.latlng);
272
+ visitMarker.setStyle({ color: "green" });
273
+ visitMarker.addTo(map);
274
+ openRecommendationModal(e.latlng.lat, e.latlng.lng);
275
+ }
276
+ });
277
+
278
+ // Remove visit marker when modal closes
279
+ $("#newVisitModal").on("hidden.bs.modal", function () {
280
+ if (visitMarker) {
281
+ map.removeLayer(visitMarker);
282
+ }
283
+ });
284
+
285
+ // Vehicle management
286
+ $("#addVehicleBtn").click(openAddVehicleModal);
287
+ $("#removeVehicleBtn").click(removeLastVehicle);
288
+ $("#confirmAddVehicle").click(confirmAddVehicle);
289
+ $("#pickLocationBtn").click(pickVehicleLocationOnMap);
290
+
291
+ // Clean up when add vehicle modal closes (only if not picking location)
292
+ $("#addVehicleModal").on("hidden.bs.modal", function () {
293
+ if (!pickingVehicleLocation) {
294
+ addingVehicleMode = false;
295
+ if (tempVehicleMarker) {
296
+ map.removeLayer(tempVehicleMarker);
297
+ tempVehicleMarker = null;
298
+ }
299
+ }
300
+ });
301
+
302
+ // Real Roads toggle handler
303
+ $(document).on('change', '#realRoadRouting', function() {
304
+ useRealRoads = $(this).is(':checked');
305
+
306
+ // If we have a demo dataset loaded, reload it with the new routing mode
307
+ if (demoDataId && !optimizing) {
308
+ scheduleId = null;
309
+ initialized = false;
310
+ homeLocationGroup.clearLayers();
311
+ homeLocationMarkerByIdMap.clear();
312
+ visitGroup.clearLayers();
313
+ visitMarkerByIdMap.clear();
314
+ routeGeometries = null;
315
+ refreshRoutePlan();
316
+ }
317
+ });
318
+
319
+ setupAjax();
320
+ fetchDemoData();
321
+ });
322
+
323
+ /*************************************** Vehicle Management **************************************/
324
+
325
+ function openAddVehicleModal() {
326
+ if (optimizing) {
327
+ alert("Cannot add vehicles while solving. Please stop solving first.");
328
+ return;
329
+ }
330
+ if (!loadedRoutePlan) {
331
+ alert("Please load a dataset first.");
332
+ return;
333
+ }
334
+
335
+ addingVehicleMode = true;
336
+
337
+ // Suggest next vehicle name
338
+ $("#vehicleName").val("").attr("placeholder", `e.g., ${getNextVehicleName()}`);
339
+
340
+ // Set default values based on existing vehicles
341
+ const existingVehicle = loadedRoutePlan.vehicles[0];
342
+ if (existingVehicle) {
343
+ $("#vehicleCapacity").val(existingVehicle.capacity || 25);
344
+ const defaultLat = existingVehicle.homeLocation[0];
345
+ const defaultLng = existingVehicle.homeLocation[1];
346
+ $("#vehicleHomeLat").val(defaultLat.toFixed(6));
347
+ $("#vehicleHomeLng").val(defaultLng.toFixed(6));
348
+ }
349
+
350
+ // Initialize departure time picker
351
+ const tomorrow = JSJoda.LocalDate.now().plusDays(1);
352
+ const defaultDeparture = tomorrow.atTime(JSJoda.LocalTime.of(6, 0));
353
+
354
+ if (vehicleDeparturePicker) {
355
+ vehicleDeparturePicker.destroy();
356
+ }
357
+ vehicleDeparturePicker = flatpickr("#vehicleDepartureTime", {
358
+ enableTime: true,
359
+ dateFormat: "Y-m-d H:i",
360
+ defaultDate: defaultDeparture.format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm'))
361
+ });
362
+
363
+ $("#addVehicleModal").modal("show");
364
+ }
365
+
366
+ function pickVehicleLocationOnMap() {
367
+ // Hide modal temporarily while user picks location
368
+ pickingVehicleLocation = true;
369
+ addingVehicleMode = true;
370
+ $("#addVehicleModal").modal("hide");
371
+
372
+ // Show hint on map
373
+ $("#mapHint").html('<i class="fas fa-crosshairs"></i> Click on the map to set vehicle depot location').removeClass("hidden");
374
+ }
375
+
376
+ function setVehicleHomeLocation(lat, lng) {
377
+ $("#vehicleHomeLat").val(lat.toFixed(6));
378
+ $("#vehicleHomeLng").val(lng.toFixed(6));
379
+ $("#vehicleLocationPreview").html(`<i class="fas fa-check text-success"></i> Location set: ${lat.toFixed(4)}, ${lng.toFixed(4)}`);
380
+
381
+ // Show temporary marker
382
+ if (tempVehicleMarker) {
383
+ map.removeLayer(tempVehicleMarker);
384
+ }
385
+ tempVehicleMarker = L.marker([lat, lng], {
386
+ icon: L.divIcon({
387
+ className: 'temp-vehicle-marker',
388
+ html: `<div style="
389
+ background-color: #6366f1;
390
+ border: 3px solid white;
391
+ border-radius: 4px;
392
+ width: 28px;
393
+ height: 28px;
394
+ display: flex;
395
+ align-items: center;
396
+ justify-content: center;
397
+ box-shadow: 0 2px 4px rgba(0,0,0,0.4);
398
+ animation: pulse 1s infinite;
399
+ "><i class="fas fa-warehouse" style="color: white; font-size: 12px;"></i></div>`,
400
+ iconSize: [28, 28],
401
+ iconAnchor: [14, 14]
402
+ })
403
+ });
404
+ tempVehicleMarker.addTo(map);
405
+
406
+ // If we were picking location, re-open the modal
407
+ if (pickingVehicleLocation) {
408
+ pickingVehicleLocation = false;
409
+ addingVehicleMode = false;
410
+ $("#addVehicleModal").modal("show");
411
+ // Restore normal map hint
412
+ $("#mapHint").html('<i class="fas fa-mouse-pointer"></i> Click on the map to add a new visit');
413
+ }
414
+ }
415
+
416
+ // Extended phonetic alphabet for generating vehicle names
417
+ const PHONETIC_NAMES = ["Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel", "India", "Juliet", "Kilo", "Lima", "Mike", "November", "Oscar", "Papa", "Quebec", "Romeo", "Sierra", "Tango", "Uniform", "Victor", "Whiskey", "X-ray", "Yankee", "Zulu"];
418
+
419
+ function getNextVehicleName() {
420
+ if (!loadedRoutePlan) return "Alpha";
421
+ const usedNames = new Set(loadedRoutePlan.vehicles.map(v => v.name));
422
+ for (const name of PHONETIC_NAMES) {
423
+ if (!usedNames.has(name)) return name;
424
+ }
425
+ // Fallback if all names used
426
+ return `Vehicle ${loadedRoutePlan.vehicles.length + 1}`;
427
+ }
428
+
429
+ async function confirmAddVehicle() {
430
+ const vehicleName = $("#vehicleName").val().trim() || getNextVehicleName();
431
+ const capacity = parseInt($("#vehicleCapacity").val());
432
+ const lat = parseFloat($("#vehicleHomeLat").val());
433
+ const lng = parseFloat($("#vehicleHomeLng").val());
434
+ const departureTime = $("#vehicleDepartureTime").val();
435
+
436
+ if (!capacity || capacity < 1) {
437
+ alert("Please enter a valid capacity (minimum 1).");
438
+ return;
439
+ }
440
+ if (isNaN(lat) || isNaN(lng)) {
441
+ alert("Please set a valid home location by clicking on the map or entering coordinates.");
442
+ return;
443
+ }
444
+ if (!departureTime) {
445
+ alert("Please set a departure time.");
446
+ return;
447
+ }
448
+
449
+ // Generate new vehicle ID
450
+ const maxId = Math.max(...loadedRoutePlan.vehicles.map(v => parseInt(v.id)), 0);
451
+ const newId = String(maxId + 1);
452
+
453
+ // Format departure time
454
+ const formattedDeparture = JSJoda.LocalDateTime.parse(
455
+ departureTime,
456
+ JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
457
+ ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
458
+
459
+ // Create new vehicle
460
+ const newVehicle = {
461
+ id: newId,
462
+ name: vehicleName,
463
+ capacity: capacity,
464
+ homeLocation: [lat, lng],
465
+ departureTime: formattedDeparture,
466
+ visits: [],
467
+ totalDemand: 0,
468
+ totalDrivingTimeSeconds: 0,
469
+ arrivalTime: formattedDeparture
470
+ };
471
+
472
+ // Add to solution
473
+ loadedRoutePlan.vehicles.push(newVehicle);
474
+
475
+ // Close modal and refresh
476
+ $("#addVehicleModal").modal("hide");
477
+ addingVehicleMode = false;
478
+
479
+ if (tempVehicleMarker) {
480
+ map.removeLayer(tempVehicleMarker);
481
+ tempVehicleMarker = null;
482
+ }
483
+
484
+ // Refresh display
485
+ await renderRoutes(loadedRoutePlan);
486
+ renderTimelines(loadedRoutePlan);
487
+
488
+ showNotification(`Vehicle "${vehicleName}" added successfully!`, "success");
489
+ }
490
+
491
+ async function removeLastVehicle() {
492
+ if (optimizing) {
493
+ alert("Cannot remove vehicles while solving. Please stop solving first.");
494
+ return;
495
+ }
496
+ if (!loadedRoutePlan || loadedRoutePlan.vehicles.length <= 1) {
497
+ alert("Cannot remove the last vehicle. At least one vehicle is required.");
498
+ return;
499
+ }
500
+
501
+ const lastVehicle = loadedRoutePlan.vehicles[loadedRoutePlan.vehicles.length - 1];
502
+
503
+ if (lastVehicle.visits && lastVehicle.visits.length > 0) {
504
+ if (!confirm(`Vehicle ${lastVehicle.id} has ${lastVehicle.visits.length} assigned visits. These will become unassigned. Continue?`)) {
505
+ return;
506
+ }
507
+ // Unassign visits from the vehicle
508
+ lastVehicle.visits.forEach(visitId => {
509
+ const visit = loadedRoutePlan.visits.find(v => v.id === visitId);
510
+ if (visit) {
511
+ visit.vehicle = null;
512
+ visit.previousVisit = null;
513
+ visit.nextVisit = null;
514
+ visit.arrivalTime = null;
515
+ visit.departureTime = null;
516
+ }
517
+ });
518
+ }
519
+
520
+ // Remove vehicle
521
+ loadedRoutePlan.vehicles.pop();
522
+
523
+ // Remove marker
524
+ const marker = homeLocationMarkerByIdMap.get(lastVehicle.id);
525
+ if (marker) {
526
+ homeLocationGroup.removeLayer(marker);
527
+ homeLocationMarkerByIdMap.delete(lastVehicle.id);
528
+ }
529
+
530
+ // Refresh display
531
+ await renderRoutes(loadedRoutePlan);
532
+ renderTimelines(loadedRoutePlan);
533
+
534
+ showNotification(`Vehicle "${lastVehicle.name || lastVehicle.id}" removed.`, "info");
535
+ }
536
+
537
+ async function removeVehicle(vehicleId) {
538
+ if (optimizing) {
539
+ alert("Cannot remove vehicles while solving. Please stop solving first.");
540
+ return;
541
+ }
542
+
543
+ const vehicleIndex = loadedRoutePlan.vehicles.findIndex(v => v.id === vehicleId);
544
+ if (vehicleIndex === -1) return;
545
+
546
+ if (loadedRoutePlan.vehicles.length <= 1) {
547
+ alert("Cannot remove the last vehicle. At least one vehicle is required.");
548
+ return;
549
+ }
550
+
551
+ const vehicle = loadedRoutePlan.vehicles[vehicleIndex];
552
+
553
+ if (vehicle.visits && vehicle.visits.length > 0) {
554
+ if (!confirm(`Vehicle ${vehicle.id} has ${vehicle.visits.length} assigned visits. These will become unassigned. Continue?`)) {
555
+ return;
556
+ }
557
+ // Unassign visits
558
+ vehicle.visits.forEach(visitId => {
559
+ const visit = loadedRoutePlan.visits.find(v => v.id === visitId);
560
+ if (visit) {
561
+ visit.vehicle = null;
562
+ visit.previousVisit = null;
563
+ visit.nextVisit = null;
564
+ visit.arrivalTime = null;
565
+ visit.departureTime = null;
566
+ }
567
+ });
568
+ }
569
+
570
+ // Remove vehicle
571
+ loadedRoutePlan.vehicles.splice(vehicleIndex, 1);
572
+
573
+ // Remove marker
574
+ const marker = homeLocationMarkerByIdMap.get(vehicleId);
575
+ if (marker) {
576
+ homeLocationGroup.removeLayer(marker);
577
+ homeLocationMarkerByIdMap.delete(vehicleId);
578
+ }
579
+
580
+ // Refresh display
581
+ await renderRoutes(loadedRoutePlan);
582
+ renderTimelines(loadedRoutePlan);
583
+
584
+ showNotification(`Vehicle "${vehicle.name || vehicleId}" removed.`, "info");
585
+ }
586
+
587
+ function showNotification(message, type = "info") {
588
+ const alertClass = type === "success" ? "alert-success" : type === "error" ? "alert-danger" : "alert-info";
589
+ const icon = type === "success" ? "fa-check-circle" : type === "error" ? "fa-exclamation-circle" : "fa-info-circle";
590
+
591
+ const notification = $(`
592
+ <div class="alert ${alertClass} alert-dismissible fade show" role="alert" style="min-width: 300px;">
593
+ <i class="fas ${icon} me-2"></i>${message}
594
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
595
+ </div>
596
+ `);
597
+
598
+ $("#notificationPanel").append(notification);
599
+
600
+ // Auto-dismiss after 3 seconds
601
+ setTimeout(() => {
602
+ notification.alert('close');
603
+ }, 3000);
604
+ }
605
+
606
+ /*************************************** Route Highlighting **************************************/
607
+
608
+ function toggleVehicleHighlight(vehicleId) {
609
+ if (highlightedVehicleId === vehicleId) {
610
+ // Already highlighted - clear it
611
+ clearRouteHighlight();
612
+ } else {
613
+ // Highlight this vehicle's route
614
+ highlightVehicleRoute(vehicleId);
615
+ }
616
+ }
617
+
618
+ function clearRouteHighlight() {
619
+ // Remove number markers
620
+ routeNumberMarkers.forEach(marker => map.removeLayer(marker));
621
+ routeNumberMarkers = [];
622
+
623
+ // Reset all vehicle icons to normal and restore opacity
624
+ if (loadedRoutePlan) {
625
+ loadedRoutePlan.vehicles.forEach(vehicle => {
626
+ const marker = homeLocationMarkerByIdMap.get(vehicle.id);
627
+ if (marker) {
628
+ marker.setIcon(createVehicleHomeIcon(vehicle, false));
629
+ marker.setOpacity(1);
630
+ }
631
+ });
632
+
633
+ // Reset all visit markers to normal and restore opacity
634
+ loadedRoutePlan.visits.forEach(visit => {
635
+ const marker = visitMarkerByIdMap.get(visit.id);
636
+ if (marker) {
637
+ const customerType = getCustomerType(visit);
638
+ const isAssigned = visit.vehicle != null;
639
+ marker.setIcon(createCustomerTypeIcon(customerType, isAssigned, false));
640
+ marker.setOpacity(1);
641
+ }
642
+ });
643
+ }
644
+
645
+ // Reset route lines
646
+ renderRouteLines();
647
+
648
+ // Update vehicle table highlighting
649
+ $("#vehicles tr").removeClass("table-active");
650
+
651
+ highlightedVehicleId = null;
652
+ }
653
+
654
+ function highlightVehicleRoute(vehicleId) {
655
+ // Clear any existing highlight first
656
+ clearRouteHighlight();
657
+
658
+ highlightedVehicleId = vehicleId;
659
+
660
+ if (!loadedRoutePlan) return;
661
+
662
+ const vehicle = loadedRoutePlan.vehicles.find(v => v.id === vehicleId);
663
+ if (!vehicle) return;
664
+
665
+ const vehicleColor = colorByVehicle(vehicle);
666
+
667
+ // Highlight the vehicle's home marker
668
+ const homeMarker = homeLocationMarkerByIdMap.get(vehicleId);
669
+ if (homeMarker) {
670
+ homeMarker.setIcon(createVehicleHomeIcon(vehicle, true));
671
+ }
672
+
673
+ // Dim other vehicles
674
+ loadedRoutePlan.vehicles.forEach(v => {
675
+ if (v.id !== vehicleId) {
676
+ const marker = homeLocationMarkerByIdMap.get(v.id);
677
+ if (marker) {
678
+ marker.setIcon(createVehicleHomeIcon(v, false));
679
+ marker.setOpacity(0.3);
680
+ }
681
+ }
682
+ });
683
+
684
+ // Get visit order for this vehicle
685
+ const visitByIdMap = new Map(loadedRoutePlan.visits.map(v => [v.id, v]));
686
+ const vehicleVisits = vehicle.visits.map(visitId => visitByIdMap.get(visitId)).filter(v => v);
687
+
688
+ // Highlight and number the visits on this route
689
+ let stopNumber = 1;
690
+ vehicleVisits.forEach(visit => {
691
+ const marker = visitMarkerByIdMap.get(visit.id);
692
+ if (marker) {
693
+ const customerType = getCustomerType(visit);
694
+ marker.setIcon(createCustomerTypeIcon(customerType, true, true, vehicleColor));
695
+ marker.setOpacity(1);
696
+
697
+ // Add number marker
698
+ const numberMarker = L.marker(visit.location, {
699
+ icon: createRouteNumberIcon(stopNumber, vehicleColor),
700
+ interactive: false,
701
+ zIndexOffset: 1000
702
+ });
703
+ numberMarker.addTo(map);
704
+ routeNumberMarkers.push(numberMarker);
705
+ stopNumber++;
706
+ }
707
+ });
708
+
709
+ // Dim visits not on this route
710
+ loadedRoutePlan.visits.forEach(visit => {
711
+ if (!vehicle.visits.includes(visit.id)) {
712
+ const marker = visitMarkerByIdMap.get(visit.id);
713
+ if (marker) {
714
+ marker.setOpacity(0.25);
715
+ }
716
+ }
717
+ });
718
+
719
+ // Highlight just this route, dim others
720
+ renderRouteLines(vehicleId);
721
+
722
+ // Highlight the row in the vehicle table
723
+ $("#vehicles tr").removeClass("table-active");
724
+ $(`#vehicle-row-${vehicleId}`).addClass("table-active");
725
+
726
+ // Add start marker (S) at depot
727
+ const startMarker = L.marker(vehicle.homeLocation, {
728
+ icon: createRouteNumberIcon("S", vehicleColor),
729
+ interactive: false,
730
+ zIndexOffset: 1000
731
+ });
732
+ startMarker.addTo(map);
733
+ routeNumberMarkers.push(startMarker);
734
+ }
735
+
736
+ function createRouteNumberIcon(number, color) {
737
+ return L.divIcon({
738
+ className: 'route-number-marker',
739
+ html: `<div style="
740
+ background-color: ${color};
741
+ color: white;
742
+ font-weight: bold;
743
+ font-size: 12px;
744
+ width: 22px;
745
+ height: 22px;
746
+ border-radius: 50%;
747
+ border: 2px solid white;
748
+ display: flex;
749
+ align-items: center;
750
+ justify-content: center;
751
+ box-shadow: 0 2px 4px rgba(0,0,0,0.4);
752
+ margin-left: 16px;
753
+ margin-top: -28px;
754
+ ">${number}</div>`,
755
+ iconSize: [22, 22],
756
+ iconAnchor: [0, 0]
757
+ });
758
+ }
759
+
760
+ async function renderRouteLines(highlightedId = null) {
761
+ routeGroup.clearLayers();
762
+
763
+ if (!loadedRoutePlan) return;
764
+
765
+ // Fetch geometries during solving (routes change)
766
+ if (scheduleId) {
767
+ routeGeometries = await fetchRouteGeometries();
768
+ }
769
+
770
+ const visitByIdMap = new Map(loadedRoutePlan.visits.map(visit => [visit.id, visit]));
771
+
772
+ for (let vehicle of loadedRoutePlan.vehicles) {
773
+ const homeLocation = vehicle.homeLocation;
774
+ const locations = vehicle.visits.map(visitId => visitByIdMap.get(visitId)?.location).filter(l => l);
775
+
776
+ const isHighlighted = highlightedId === null || vehicle.id === highlightedId;
777
+ const color = colorByVehicle(vehicle);
778
+ const weight = isHighlighted && highlightedId !== null ? 5 : 3;
779
+ const opacity = isHighlighted ? 1 : 0.2;
780
+
781
+ const vehicleGeometry = routeGeometries?.[vehicle.id];
782
+
783
+ if (vehicleGeometry && vehicleGeometry.length > 0) {
784
+ // Draw real road routes using decoded polylines
785
+ for (const encodedSegment of vehicleGeometry) {
786
+ if (encodedSegment) {
787
+ const points = decodePolyline(encodedSegment);
788
+ if (points.length > 0) {
789
+ L.polyline(points, {
790
+ color: color,
791
+ weight: weight,
792
+ opacity: opacity
793
+ }).addTo(routeGroup);
794
+ }
795
+ }
796
+ }
797
+ } else if (locations.length > 0) {
798
+ // Fallback to straight lines if no geometry available
799
+ L.polyline([homeLocation, ...locations, homeLocation], {
800
+ color: color,
801
+ weight: weight,
802
+ opacity: opacity
803
+ }).addTo(routeGroup);
804
+ }
805
+ }
806
+ }
807
+
808
+ function colorByVehicle(vehicle) {
809
+ return vehicle === null ? null : pickColor("vehicle" + vehicle.id);
810
+ }
811
+
812
+ // Customer type definitions matching demo_data.py
813
+ const CUSTOMER_TYPES = {
814
+ RESTAURANT: { label: "Restaurant", icon: "fa-utensils", color: "#f59e0b", windowStart: "06:00", windowEnd: "10:00", minService: 20, maxService: 40 },
815
+ BUSINESS: { label: "Business", icon: "fa-building", color: "#3b82f6", windowStart: "09:00", windowEnd: "17:00", minService: 15, maxService: 30 },
816
+ RESIDENTIAL: { label: "Residential", icon: "fa-home", color: "#10b981", windowStart: "17:00", windowEnd: "20:00", minService: 5, maxService: 10 },
817
+ };
818
+
819
+ function getCustomerType(visit) {
820
+ const startTime = showTimeOnly(visit.minStartTime).toString();
821
+ const endTime = showTimeOnly(visit.maxEndTime).toString();
822
+
823
+ for (const [type, config] of Object.entries(CUSTOMER_TYPES)) {
824
+ if (startTime === config.windowStart && endTime === config.windowEnd) {
825
+ return { type, ...config };
826
+ }
827
+ }
828
+ return { type: "UNKNOWN", label: "Custom", icon: "fa-question", color: "#6b7280", windowStart: startTime, windowEnd: endTime };
829
+ }
830
+
831
+ function formatDrivingTime(drivingTimeInSeconds) {
832
+ return `${Math.floor(drivingTimeInSeconds / 3600)}h ${Math.round((drivingTimeInSeconds % 3600) / 60)}m`;
833
+ }
834
+
835
+ function homeLocationPopupContent(vehicle) {
836
+ const color = colorByVehicle(vehicle);
837
+ const visitCount = vehicle.visits ? vehicle.visits.length : 0;
838
+ const vehicleName = vehicle.name || `Vehicle ${vehicle.id}`;
839
+ return `<div style="min-width: 150px;">
840
+ <h5 style="color: ${color};"><i class="fas fa-truck"></i> ${vehicleName}</h5>
841
+ <p class="mb-1"><strong>Depot Location</strong></p>
842
+ <p class="mb-1"><i class="fas fa-box"></i> Capacity: ${vehicle.capacity}</p>
843
+ <p class="mb-1"><i class="fas fa-route"></i> Visits: ${visitCount}</p>
844
+ <p class="mb-0"><i class="fas fa-clock"></i> Departs: ${showTimeOnly(vehicle.departureTime)}</p>
845
+ </div>`;
846
+ }
847
+
848
+ function visitPopupContent(visit) {
849
+ const customerType = getCustomerType(visit);
850
+ const serviceDurationMinutes = Math.round(visit.serviceDuration / 60);
851
+ const arrival = visit.arrivalTime
852
+ ? `<h6>Arrival at ${showTimeOnly(visit.arrivalTime)}.</h6>`
853
+ : "";
854
+ return `<h5><i class="fas ${customerType.icon}" style="color: ${customerType.color}"></i> ${visit.name}</h5>
855
+ <h6><span class="badge" style="background-color: ${customerType.color}">${customerType.label}</span></h6>
856
+ <h6>Cargo: ${visit.demand} units</h6>
857
+ <h6>Service time: ${serviceDurationMinutes} min</h6>
858
+ <h6>Window: ${showTimeOnly(visit.minStartTime)} - ${showTimeOnly(visit.maxEndTime)}</h6>
859
+ ${arrival}`;
860
+ }
861
+
862
+ function showTimeOnly(localDateTimeString) {
863
+ return JSJoda.LocalDateTime.parse(localDateTimeString).toLocalTime();
864
+ }
865
+
866
+ function createVehicleHomeIcon(vehicle, isHighlighted = false) {
867
+ const color = colorByVehicle(vehicle);
868
+ const size = isHighlighted ? 36 : 28;
869
+ const fontSize = isHighlighted ? 14 : 11;
870
+ const borderWidth = isHighlighted ? 4 : 3;
871
+ const shadow = isHighlighted
872
+ ? `0 0 0 4px ${color}40, 0 4px 8px rgba(0,0,0,0.5)`
873
+ : '0 2px 4px rgba(0,0,0,0.4)';
874
+
875
+ return L.divIcon({
876
+ className: 'vehicle-home-marker',
877
+ html: `<div style="
878
+ background-color: ${color};
879
+ border: ${borderWidth}px solid white;
880
+ border-radius: 50%;
881
+ width: ${size}px;
882
+ height: ${size}px;
883
+ display: flex;
884
+ align-items: center;
885
+ justify-content: center;
886
+ box-shadow: ${shadow};
887
+ transition: all 0.2s ease;
888
+ "><i class="fas fa-truck" style="color: white; font-size: ${fontSize}px;"></i></div>`,
889
+ iconSize: [size, size],
890
+ iconAnchor: [size/2, size/2],
891
+ popupAnchor: [0, -size/2]
892
+ });
893
+ }
894
+
895
+ function getHomeLocationMarker(vehicle) {
896
+ let marker = homeLocationMarkerByIdMap.get(vehicle.id);
897
+ if (marker) {
898
+ marker.setIcon(createVehicleHomeIcon(vehicle));
899
+ return marker;
900
+ }
901
+ marker = L.marker(vehicle.homeLocation, {
902
+ icon: createVehicleHomeIcon(vehicle)
903
+ });
904
+ marker.addTo(homeLocationGroup).bindPopup();
905
+ homeLocationMarkerByIdMap.set(vehicle.id, marker);
906
+ return marker;
907
+ }
908
+
909
+ function createCustomerTypeIcon(customerType, isAssigned = false, isHighlighted = false, highlightColor = null) {
910
+ const borderColor = isHighlighted && highlightColor
911
+ ? highlightColor
912
+ : (isAssigned ? customerType.color : '#6b7280');
913
+ const size = isHighlighted ? 38 : 32;
914
+ const fontSize = isHighlighted ? 16 : 14;
915
+ const borderWidth = isHighlighted ? 4 : 3;
916
+ const shadow = isHighlighted
917
+ ? `0 0 0 4px ${highlightColor}40, 0 4px 8px rgba(0,0,0,0.4)`
918
+ : '0 2px 4px rgba(0,0,0,0.3)';
919
+
920
+ return L.divIcon({
921
+ className: 'customer-marker',
922
+ html: `<div style="
923
+ background-color: white;
924
+ border: ${borderWidth}px solid ${borderColor};
925
+ border-radius: 50%;
926
+ width: ${size}px;
927
+ height: ${size}px;
928
+ display: flex;
929
+ align-items: center;
930
+ justify-content: center;
931
+ box-shadow: ${shadow};
932
+ transition: all 0.2s ease;
933
+ "><i class="fas ${customerType.icon}" style="color: ${customerType.color}; font-size: ${fontSize}px;"></i></div>`,
934
+ iconSize: [size, size],
935
+ iconAnchor: [size/2, size/2],
936
+ popupAnchor: [0, -size/2]
937
+ });
938
+ }
939
+
940
+ function getVisitMarker(visit) {
941
+ let marker = visitMarkerByIdMap.get(visit.id);
942
+ const customerType = getCustomerType(visit);
943
+ const isAssigned = visit.vehicle != null;
944
+
945
+ if (marker) {
946
+ // Update icon if assignment status changed
947
+ marker.setIcon(createCustomerTypeIcon(customerType, isAssigned));
948
+ return marker;
949
+ }
950
+
951
+ marker = L.marker(visit.location, {
952
+ icon: createCustomerTypeIcon(customerType, isAssigned)
953
+ });
954
+ marker.addTo(visitGroup).bindPopup();
955
+ visitMarkerByIdMap.set(visit.id, marker);
956
+ return marker;
957
+ }
958
+
959
+ async function renderRoutes(solution) {
960
+ if (!initialized) {
961
+ const bounds = [solution.southWestCorner, solution.northEastCorner];
962
+ map.fitBounds(bounds);
963
+ }
964
+ // Vehicles
965
+ vehiclesTable.children().remove();
966
+ const canRemove = solution.vehicles.length > 1;
967
+ solution.vehicles.forEach(function (vehicle) {
968
+ getHomeLocationMarker(vehicle).setPopupContent(
969
+ homeLocationPopupContent(vehicle),
970
+ );
971
+ const { id, capacity, totalDemand, totalDrivingTimeSeconds } = vehicle;
972
+ const percentage = Math.min((totalDemand / capacity) * 100, 100);
973
+ const overCapacity = totalDemand > capacity;
974
+ const color = colorByVehicle(vehicle);
975
+ const progressBarColor = overCapacity ? 'bg-danger' : '';
976
+ const isHighlighted = highlightedVehicleId === id;
977
+ const visitCount = vehicle.visits ? vehicle.visits.length : 0;
978
+ const vehicleName = vehicle.name || `Vehicle ${id}`;
979
+
980
+ vehiclesTable.append(`
981
+ <tr id="vehicle-row-${id}" class="vehicle-row ${isHighlighted ? 'table-active' : ''}" style="cursor: pointer;">
982
+ <td onclick="toggleVehicleHighlight('${id}')">
983
+ <div style="background-color: ${color}; width: 1.5rem; height: 1.5rem; border-radius: 50%; display: flex; align-items: center; justify-content: center; ${isHighlighted ? 'box-shadow: 0 0 0 3px ' + color + '40;' : ''}">
984
+ <i class="fas fa-truck" style="color: white; font-size: 0.65rem;"></i>
985
+ </div>
986
+ </td>
987
+ <td onclick="toggleVehicleHighlight('${id}')">
988
+ <strong>${vehicleName}</strong>
989
+ <br><small class="text-muted">${visitCount} stops</small>
990
+ </td>
991
+ <td onclick="toggleVehicleHighlight('${id}')">
992
+ <div class="progress" style="height: 18px;" data-bs-toggle="tooltip" data-bs-placement="left"
993
+ title="Cargo: ${totalDemand} / Capacity: ${capacity}${overCapacity ? ' (OVER CAPACITY!)' : ''}">
994
+ <div class="progress-bar ${progressBarColor}" role="progressbar" style="width: ${percentage}%; font-size: 0.7rem; transition: width 0.3s ease;">
995
+ ${totalDemand}/${capacity}
996
+ </div>
997
+ </div>
998
+ </td>
999
+ <td onclick="toggleVehicleHighlight('${id}')" style="font-size: 0.85rem;">
1000
+ ${formatDrivingTime(totalDrivingTimeSeconds)}
1001
+ </td>
1002
+ <td>
1003
+ ${canRemove ? `<button class="btn btn-sm btn-outline-danger p-0 px-1" onclick="event.stopPropagation(); removeVehicle('${id}')" title="Remove vehicle ${vehicleName}">
1004
+ <i class="fas fa-times" style="font-size: 0.7rem;"></i>
1005
+ </button>` : ''}
1006
+ </td>
1007
+ </tr>`);
1008
+ });
1009
+ // Visits
1010
+ solution.visits.forEach(function (visit) {
1011
+ getVisitMarker(visit).setPopupContent(visitPopupContent(visit));
1012
+ });
1013
+ // Route - use the dedicated function which handles highlighting (await to ensure geometries load)
1014
+ await renderRouteLines(highlightedVehicleId);
1015
+
1016
+ // Summary
1017
+ $("#score").text(solution.score ? `Score: ${solution.score}` : "Score: ?");
1018
+ $("#drivingTime").text(formatDrivingTime(solution.totalDrivingTimeSeconds));
1019
+ }
1020
+
1021
+ function renderTimelines(routePlan) {
1022
+ byVehicleGroupData.clear();
1023
+ byVisitGroupData.clear();
1024
+ byVehicleItemData.clear();
1025
+ byVisitItemData.clear();
1026
+
1027
+ // Build lookup maps for O(1) access
1028
+ const vehicleById = new Map(routePlan.vehicles.map(v => [v.id, v]));
1029
+ const visitById = new Map(routePlan.visits.map(v => [v.id, v]));
1030
+ const visitOrderMap = new Map();
1031
+
1032
+ // Build stop order for each visit
1033
+ routePlan.vehicles.forEach(vehicle => {
1034
+ vehicle.visits.forEach((visitId, index) => {
1035
+ visitOrderMap.set(visitId, index + 1);
1036
+ });
1037
+ });
1038
+
1039
+ // Vehicle groups with names and status summary
1040
+ $.each(routePlan.vehicles, function (index, vehicle) {
1041
+ const vehicleName = vehicle.name || `Vehicle ${vehicle.id}`;
1042
+ const { totalDemand, capacity } = vehicle;
1043
+ const percentage = Math.min((totalDemand / capacity) * 100, 100);
1044
+ const overCapacity = totalDemand > capacity;
1045
+
1046
+ // Count late visits for this vehicle
1047
+ const vehicleVisits = vehicle.visits.map(id => visitById.get(id)).filter(v => v);
1048
+ const lateCount = vehicleVisits.filter(v => {
1049
+ if (!v.departureTime) return false;
1050
+ const departure = JSJoda.LocalDateTime.parse(v.departureTime);
1051
+ const maxEnd = JSJoda.LocalDateTime.parse(v.maxEndTime);
1052
+ return departure.isAfter(maxEnd);
1053
+ }).length;
1054
+
1055
+ const statusIcon = lateCount > 0
1056
+ ? `<i class="fas fa-exclamation-triangle timeline-status-late timeline-status-icon" title="${lateCount} late"></i>`
1057
+ : vehicle.visits.length > 0
1058
+ ? `<i class="fas fa-check-circle timeline-status-ontime timeline-status-icon" title="All on-time"></i>`
1059
+ : '';
1060
+
1061
+ const progressBarClass = overCapacity ? 'bg-danger' : '';
1062
+
1063
+ const vehicleWithLoad = `
1064
+ <h5 class="card-title mb-1">${vehicleName}${statusIcon}</h5>
1065
+ <div class="progress" style="height: 16px;" title="Cargo: ${totalDemand} / ${capacity}">
1066
+ <div class="progress-bar ${progressBarClass}" role="progressbar" style="width: ${percentage}%">
1067
+ ${totalDemand}/${capacity}
1068
+ </div>
1069
+ </div>`;
1070
+ byVehicleGroupData.add({ id: vehicle.id, content: vehicleWithLoad });
1071
+ });
1072
+
1073
+ $.each(routePlan.visits, function (index, visit) {
1074
+ const minStartTime = JSJoda.LocalDateTime.parse(visit.minStartTime);
1075
+ const maxEndTime = JSJoda.LocalDateTime.parse(visit.maxEndTime);
1076
+ const serviceDuration = JSJoda.Duration.ofSeconds(visit.serviceDuration);
1077
+ const customerType = getCustomerType(visit);
1078
+ const stopNumber = visitOrderMap.get(visit.id);
1079
+
1080
+ const visitGroupElement = $(`<div/>`).append(
1081
+ $(`<h5 class="card-title mb-1"/>`).html(
1082
+ `<i class="fas ${customerType.icon}" style="color: ${customerType.color}"></i> ${visit.name}`
1083
+ ),
1084
+ ).append(
1085
+ $(`<small class="text-muted"/>`).text(customerType.label)
1086
+ );
1087
+ byVisitGroupData.add({
1088
+ id: visit.id,
1089
+ content: visitGroupElement.html(),
1090
+ });
1091
+
1092
+ // Time window per visit.
1093
+ byVisitItemData.add({
1094
+ id: visit.id + "_readyToDue",
1095
+ group: visit.id,
1096
+ start: visit.minStartTime,
1097
+ end: visit.maxEndTime,
1098
+ type: "background",
1099
+ style: "background-color: #8AE23433",
1100
+ });
1101
+
1102
+ if (visit.vehicle == null) {
1103
+ const byJobJobElement = $(`<div/>`).append(
1104
+ $(`<span/>`).html(`<i class="fas fa-exclamation-circle text-danger me-1"></i>Unassigned`),
1105
+ );
1106
+
1107
+ // Unassigned are shown at the beginning of the visit's time window; the length is the service duration.
1108
+ byVisitItemData.add({
1109
+ id: visit.id + "_unassigned",
1110
+ group: visit.id,
1111
+ content: byJobJobElement.html(),
1112
+ start: minStartTime.toString(),
1113
+ end: minStartTime.plus(serviceDuration).toString(),
1114
+ style: "background-color: #EF292999",
1115
+ });
1116
+ } else {
1117
+ const arrivalTime = JSJoda.LocalDateTime.parse(visit.arrivalTime);
1118
+ const beforeReady = arrivalTime.isBefore(minStartTime);
1119
+ const departureTime = JSJoda.LocalDateTime.parse(visit.departureTime);
1120
+ const afterDue = departureTime.isAfter(maxEndTime);
1121
+
1122
+ // Get vehicle info for display
1123
+ const vehicleInfo = vehicleById.get(visit.vehicle);
1124
+ const vehicleName = vehicleInfo ? (vehicleInfo.name || `Vehicle ${visit.vehicle}`) : `Vehicle ${visit.vehicle}`;
1125
+
1126
+ // Stop badge for service segment
1127
+ const stopBadge = stopNumber ? `<span class="timeline-stop-badge">${stopNumber}</span>` : '';
1128
+
1129
+ // Status icon based on timing
1130
+ const statusIcon = afterDue
1131
+ ? `<i class="fas fa-exclamation-triangle timeline-status-late timeline-status-icon" title="Late"></i>`
1132
+ : `<i class="fas fa-check timeline-status-ontime timeline-status-icon" title="On-time"></i>`;
1133
+
1134
+ const byVehicleElement = $(`<div/>`)
1135
+ .append($(`<span/>`).html(
1136
+ `${stopBadge}<i class="fas ${customerType.icon}" style="color: ${customerType.color}"></i> ${visit.name}${statusIcon}`
1137
+ ));
1138
+
1139
+ const byVisitElement = $(`<div/>`)
1140
+ .append(
1141
+ $(`<span/>`).html(
1142
+ `${stopBadge}${vehicleName}${statusIcon}`
1143
+ ),
1144
+ );
1145
+
1146
+ const byVehicleTravelElement = $(`<div/>`).append(
1147
+ $(`<span/>`).html(`<i class="fas fa-route text-warning me-1"></i>Travel`),
1148
+ );
1149
+
1150
+ const previousDeparture = arrivalTime.minusSeconds(
1151
+ visit.drivingTimeSecondsFromPreviousStandstill,
1152
+ );
1153
+ byVehicleItemData.add({
1154
+ id: visit.id + "_travel",
1155
+ group: visit.vehicle,
1156
+ subgroup: visit.vehicle,
1157
+ content: byVehicleTravelElement.html(),
1158
+ start: previousDeparture.toString(),
1159
+ end: visit.arrivalTime,
1160
+ style: "background-color: #f7dd8f90",
1161
+ });
1162
+
1163
+ if (beforeReady) {
1164
+ const byVehicleWaitElement = $(`<div/>`).append(
1165
+ $(`<span/>`).html(`<i class="fas fa-clock timeline-status-early me-1"></i>Wait`),
1166
+ );
1167
+
1168
+ byVehicleItemData.add({
1169
+ id: visit.id + "_wait",
1170
+ group: visit.vehicle,
1171
+ subgroup: visit.vehicle,
1172
+ content: byVehicleWaitElement.html(),
1173
+ start: visit.arrivalTime,
1174
+ end: visit.minStartTime,
1175
+ style: "background-color: #93c5fd80",
1176
+ });
1177
+ }
1178
+
1179
+ let serviceElementBackground = afterDue ? "#EF292999" : "#83C15955";
1180
+
1181
+ byVehicleItemData.add({
1182
+ id: visit.id + "_service",
1183
+ group: visit.vehicle,
1184
+ subgroup: visit.vehicle,
1185
+ content: byVehicleElement.html(),
1186
+ start: visit.startServiceTime,
1187
+ end: visit.departureTime,
1188
+ style: "background-color: " + serviceElementBackground,
1189
+ });
1190
+ byVisitItemData.add({
1191
+ id: visit.id,
1192
+ group: visit.id,
1193
+ content: byVisitElement.html(),
1194
+ start: visit.startServiceTime,
1195
+ end: visit.departureTime,
1196
+ style: "background-color: " + serviceElementBackground,
1197
+ });
1198
+ }
1199
+ });
1200
+
1201
+ $.each(routePlan.vehicles, function (index, vehicle) {
1202
+ if (vehicle.visits.length > 0) {
1203
+ let lastVisit = routePlan.visits
1204
+ .filter(
1205
+ (visit) => visit.id == vehicle.visits[vehicle.visits.length - 1],
1206
+ )
1207
+ .pop();
1208
+ if (lastVisit) {
1209
+ byVehicleItemData.add({
1210
+ id: vehicle.id + "_travelBackToHomeLocation",
1211
+ group: vehicle.id,
1212
+ subgroup: vehicle.id,
1213
+ content: $(`<div/>`)
1214
+ .append($(`<span/>`).html(`<i class="fas fa-home text-secondary me-1"></i>Return`))
1215
+ .html(),
1216
+ start: lastVisit.departureTime,
1217
+ end: vehicle.arrivalTime,
1218
+ style: "background-color: #f7dd8f90",
1219
+ });
1220
+ }
1221
+ }
1222
+ });
1223
+
1224
+ if (!initialized) {
1225
+ if (byVehicleTimeline) {
1226
+ byVehicleTimeline.setWindow(
1227
+ routePlan.startDateTime,
1228
+ routePlan.endDateTime,
1229
+ );
1230
+ }
1231
+ if (byVisitTimeline) {
1232
+ byVisitTimeline.setWindow(routePlan.startDateTime, routePlan.endDateTime);
1233
+ }
1234
+ }
1235
+ }
1236
+
1237
+ function analyze() {
1238
+ // see score-analysis.js
1239
+ analyzeScore(loadedRoutePlan, "/route-plans/analyze");
1240
+ }
1241
+
1242
+ function openRecommendationModal(lat, lng) {
1243
+ if (!('score' in loadedRoutePlan) || optimizing) {
1244
+ map.removeLayer(visitMarker);
1245
+ visitMarker = null;
1246
+ let message = "Please click the Solve button before adding new visits.";
1247
+ if (optimizing) {
1248
+ message = "Please wait for the solving process to finish.";
1249
+ }
1250
+ alert(message);
1251
+ return;
1252
+ }
1253
+ // see recommended-fit.js
1254
+ const visitId = Math.max(...loadedRoutePlan.visits.map(c => parseInt(c.id))) + 1;
1255
+ newVisit = {id: visitId, location: [lat, lng]};
1256
+ addNewVisit(visitId, lat, lng, map, visitMarker);
1257
+ }
1258
+
1259
+ function getRecommendationsModal() {
1260
+ let formValid = true;
1261
+ formValid = validateFormField(newVisit, 'name', '#inputName') && formValid;
1262
+ formValid = validateFormField(newVisit, 'demand', '#inputDemand') && formValid;
1263
+ formValid = validateFormField(newVisit, 'minStartTime', '#inputMinStartTime') && formValid;
1264
+ formValid = validateFormField(newVisit, 'maxEndTime', '#inputMaxStartTime') && formValid;
1265
+ formValid = validateFormField(newVisit, 'serviceDuration', '#inputDuration') && formValid;
1266
+
1267
+ if (formValid) {
1268
+ const updatedMinStartTime = JSJoda.LocalDateTime.parse(
1269
+ newVisit['minStartTime'],
1270
+ JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
1271
+ ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
1272
+
1273
+ const updatedMaxEndTime = JSJoda.LocalDateTime.parse(
1274
+ newVisit['maxEndTime'],
1275
+ JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
1276
+ ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
1277
+
1278
+ const updatedVisit = {
1279
+ ...newVisit,
1280
+ serviceDuration: parseInt(newVisit['serviceDuration']) * 60, // Convert minutes to seconds
1281
+ minStartTime: updatedMinStartTime,
1282
+ maxEndTime: updatedMaxEndTime
1283
+ };
1284
+
1285
+ let updatedVisitList = [...loadedRoutePlan['visits']];
1286
+ updatedVisitList.push(updatedVisit);
1287
+ let updatedSolution = {...loadedRoutePlan, visits: updatedVisitList};
1288
+
1289
+ // see recommended-fit.js
1290
+ requestRecommendations(updatedVisit.id, updatedSolution, "/route-plans/recommendation");
1291
+ }
1292
+ }
1293
+
1294
+ function validateFormField(target, fieldName, inputName) {
1295
+ target[fieldName] = $(inputName).val();
1296
+ if ($(inputName).val() == "") {
1297
+ $(inputName).addClass("is-invalid");
1298
+ } else {
1299
+ $(inputName).removeClass("is-invalid");
1300
+ }
1301
+ return $(inputName).val() != "";
1302
+ }
1303
+
1304
+ function applyRecommendationModal(recommendations) {
1305
+ let checkedRecommendation = null;
1306
+ recommendations.forEach((recommendation, index) => {
1307
+ if ($('#option' + index).is(":checked")) {
1308
+ checkedRecommendation = recommendations[index];
1309
+ }
1310
+ });
1311
+
1312
+ if (!checkedRecommendation) {
1313
+ alert("Please select a recommendation.");
1314
+ return;
1315
+ }
1316
+
1317
+ const updatedMinStartTime = JSJoda.LocalDateTime.parse(
1318
+ newVisit['minStartTime'],
1319
+ JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
1320
+ ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
1321
+
1322
+ const updatedMaxEndTime = JSJoda.LocalDateTime.parse(
1323
+ newVisit['maxEndTime'],
1324
+ JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
1325
+ ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
1326
+
1327
+ const updatedVisit = {
1328
+ ...newVisit,
1329
+ serviceDuration: parseInt(newVisit['serviceDuration']) * 60, // Convert minutes to seconds
1330
+ minStartTime: updatedMinStartTime,
1331
+ maxEndTime: updatedMaxEndTime
1332
+ };
1333
+
1334
+ let updatedVisitList = [...loadedRoutePlan['visits']];
1335
+ updatedVisitList.push(updatedVisit);
1336
+ let updatedSolution = {...loadedRoutePlan, visits: updatedVisitList};
1337
+
1338
+ // see recommended-fit.js
1339
+ applyRecommendation(
1340
+ updatedSolution,
1341
+ newVisit.id,
1342
+ checkedRecommendation.proposition.vehicleId,
1343
+ checkedRecommendation.proposition.index,
1344
+ "/route-plans/recommendation/apply"
1345
+ );
1346
+ }
1347
+
1348
+ async function updateSolutionWithNewVisit(newSolution) {
1349
+ loadedRoutePlan = newSolution;
1350
+ await renderRoutes(newSolution);
1351
+ renderTimelines(newSolution);
1352
+ $('#newVisitModal').modal('hide');
1353
+ }
1354
+
1355
+ // TODO: move the general functionality to the webjar.
1356
+
1357
+ function setupAjax() {
1358
+ $.ajaxSetup({
1359
+ headers: {
1360
+ "Content-Type": "application/json",
1361
+ Accept: "application/json,text/plain", // plain text is required by solve() returning UUID of the solver job
1362
+ },
1363
+ });
1364
+
1365
+ // Extend jQuery to support $.put() and $.delete()
1366
+ jQuery.each(["put", "delete"], function (i, method) {
1367
+ jQuery[method] = function (url, data, callback, type) {
1368
+ if (jQuery.isFunction(data)) {
1369
+ type = type || callback;
1370
+ callback = data;
1371
+ data = undefined;
1372
+ }
1373
+ return jQuery.ajax({
1374
+ url: url,
1375
+ type: method,
1376
+ dataType: type,
1377
+ data: data,
1378
+ success: callback,
1379
+ });
1380
+ };
1381
+ });
1382
+ }
1383
+
1384
+ function solve() {
1385
+ // Clear geometry cache - will be refreshed when solution updates
1386
+ routeGeometries = null;
1387
+
1388
+ $.ajax({
1389
+ url: "/route-plans",
1390
+ type: "POST",
1391
+ data: JSON.stringify(loadedRoutePlan),
1392
+ contentType: "application/json",
1393
+ dataType: "text",
1394
+ success: function (data) {
1395
+ scheduleId = data.replace(/"/g, ""); // Remove quotes from UUID
1396
+ refreshSolvingButtons(true);
1397
+ },
1398
+ error: function (xhr, ajaxOptions, thrownError) {
1399
+ showError("Start solving failed.", xhr);
1400
+ refreshSolvingButtons(false);
1401
+ },
1402
+ });
1403
+ }
1404
+
1405
+ function refreshSolvingButtons(solving) {
1406
+ optimizing = solving;
1407
+ if (solving) {
1408
+ $("#solveButton").hide();
1409
+ $("#visitButton").hide();
1410
+ $("#stopSolvingButton").show();
1411
+ $("#solvingSpinner").addClass("active");
1412
+ $("#mapHint").addClass("hidden");
1413
+ if (autoRefreshIntervalId == null) {
1414
+ autoRefreshIntervalId = setInterval(refreshRoutePlan, 2000);
1415
+ }
1416
+ } else {
1417
+ $("#solveButton").show();
1418
+ $("#visitButton").show();
1419
+ $("#stopSolvingButton").hide();
1420
+ $("#solvingSpinner").removeClass("active");
1421
+ $("#mapHint").removeClass("hidden");
1422
+ if (autoRefreshIntervalId != null) {
1423
+ clearInterval(autoRefreshIntervalId);
1424
+ autoRefreshIntervalId = null;
1425
+ }
1426
+ }
1427
+ }
1428
+
1429
+ async function refreshRoutePlan() {
1430
+ let path = "/route-plans/" + scheduleId;
1431
+ let isLoadingDemoData = scheduleId === null;
1432
+
1433
+ if (isLoadingDemoData) {
1434
+ if (demoDataId === null) {
1435
+ alert("Please select a test data set.");
1436
+ return;
1437
+ }
1438
+
1439
+ // Clear geometry cache when loading new demo data
1440
+ routeGeometries = null;
1441
+
1442
+ // Use SSE streaming for demo data loading to show progress
1443
+ try {
1444
+ const routePlan = await loadDemoDataWithProgress(demoDataId);
1445
+ loadedRoutePlan = routePlan;
1446
+ refreshSolvingButtons(
1447
+ routePlan.solverStatus != null &&
1448
+ routePlan.solverStatus !== "NOT_SOLVING",
1449
+ );
1450
+ await renderRoutes(routePlan);
1451
+ renderTimelines(routePlan);
1452
+ initialized = true;
1453
+ } catch (error) {
1454
+ showError("Getting demo data has failed: " + error.message, {});
1455
+ refreshSolvingButtons(false);
1456
+ }
1457
+ return;
1458
+ }
1459
+
1460
+ // Loading existing route plan (during solving)
1461
+ try {
1462
+ const routePlan = await $.getJSON(path);
1463
+ loadedRoutePlan = routePlan;
1464
+ refreshSolvingButtons(
1465
+ routePlan.solverStatus != null &&
1466
+ routePlan.solverStatus !== "NOT_SOLVING",
1467
+ );
1468
+ await renderRoutes(routePlan);
1469
+ renderTimelines(routePlan);
1470
+ initialized = true;
1471
+ } catch (error) {
1472
+ showError("Getting route plan has failed.", error);
1473
+ refreshSolvingButtons(false);
1474
+ }
1475
+ }
1476
+
1477
+ function stopSolving() {
1478
+ $.delete("/route-plans/" + scheduleId, function () {
1479
+ refreshSolvingButtons(false);
1480
+ refreshRoutePlan();
1481
+ }).fail(function (xhr, ajaxOptions, thrownError) {
1482
+ showError("Stop solving failed.", xhr);
1483
+ });
1484
+ }
1485
+
1486
+ function fetchDemoData() {
1487
+ $.get("/demo-data", function (data) {
1488
+ data.forEach(function (item) {
1489
+ $("#testDataButton").append(
1490
+ $(
1491
+ '<a id="' +
1492
+ item +
1493
+ 'TestData" class="dropdown-item" href="#">' +
1494
+ item +
1495
+ "</a>",
1496
+ ),
1497
+ );
1498
+
1499
+ $("#" + item + "TestData").click(function () {
1500
+ switchDataDropDownItemActive(item);
1501
+ scheduleId = null;
1502
+ demoDataId = item;
1503
+ initialized = false;
1504
+ homeLocationGroup.clearLayers();
1505
+ homeLocationMarkerByIdMap.clear();
1506
+ visitGroup.clearLayers();
1507
+ visitMarkerByIdMap.clear();
1508
+ refreshRoutePlan();
1509
+ });
1510
+ });
1511
+
1512
+ demoDataId = data[0];
1513
+ switchDataDropDownItemActive(demoDataId);
1514
+
1515
+ refreshRoutePlan();
1516
+ }).fail(function (xhr, ajaxOptions, thrownError) {
1517
+ // disable this page as there is no data
1518
+ $("#demo").empty();
1519
+ $("#demo").html(
1520
+ '<h1><p style="justify-content: center">No test data available</p></h1>',
1521
+ );
1522
+ });
1523
+ }
1524
+
1525
+ function switchDataDropDownItemActive(newItem) {
1526
+ activeCssClass = "active";
1527
+ $("#testDataButton > a." + activeCssClass).removeClass(activeCssClass);
1528
+ $("#" + newItem + "TestData").addClass(activeCssClass);
1529
+ }
1530
+
1531
+ function copyTextToClipboard(id) {
1532
+ var text = $("#" + id)
1533
+ .text()
1534
+ .trim();
1535
+
1536
+ var dummy = document.createElement("textarea");
1537
+ document.body.appendChild(dummy);
1538
+ dummy.value = text;
1539
+ dummy.select();
1540
+ document.execCommand("copy");
1541
+ document.body.removeChild(dummy);
1542
+ }
1543
+
1544
+ function replaceQuickstartSolverForgeAutoHeaderFooter() {
1545
+ const solverforgeHeader = $("header#solverforge-auto-header");
1546
+ if (solverforgeHeader != null) {
1547
+ solverforgeHeader.css("background-color", "#ffffff");
1548
+ solverforgeHeader.append(
1549
+ $(`<div class="container-fluid">
1550
+ <nav class="navbar sticky-top navbar-expand-lg shadow-sm mb-3" style="background-color: #ffffff;">
1551
+ <a class="navbar-brand" href="https://www.solverforge.org">
1552
+ <img src="/webjars/solverforge/img/solverforge-horizontal.svg" alt="SolverForge logo" width="400">
1553
+ </a>
1554
+ <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
1555
+ <span class="navbar-toggler-icon"></span>
1556
+ </button>
1557
+ <div class="collapse navbar-collapse" id="navbarNav">
1558
+ <ul class="nav nav-pills">
1559
+ <li class="nav-item active" id="navUIItem">
1560
+ <button class="nav-link active" id="navUI" data-bs-toggle="pill" data-bs-target="#demo" type="button" style="color: #1f2937;">Demo UI</button>
1561
+ </li>
1562
+ <li class="nav-item" id="navRestItem">
1563
+ <button class="nav-link" id="navRest" data-bs-toggle="pill" data-bs-target="#rest" type="button" style="color: #1f2937;">Guide</button>
1564
+ </li>
1565
+ <li class="nav-item" id="navOpenApiItem">
1566
+ <button class="nav-link" id="navOpenApi" data-bs-toggle="pill" data-bs-target="#openapi" type="button" style="color: #1f2937;">REST API</button>
1567
+ </li>
1568
+ </ul>
1569
+ </div>
1570
+ <div class="ms-auto d-flex align-items-center gap-3">
1571
+ <div class="form-check form-switch d-flex align-items-center" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Enable real road routing using OpenStreetMap data. Slower initial load (~5-15s for download), but shows accurate road routes instead of straight lines.">
1572
+ <input class="form-check-input" type="checkbox" id="realRoadRouting" style="width: 2.5em; height: 1.25em; cursor: pointer;">
1573
+ <label class="form-check-label ms-2" for="realRoadRouting" style="white-space: nowrap; cursor: pointer;">
1574
+ <i class="fas fa-road"></i> Real Roads
1575
+ </label>
1576
+ </div>
1577
+ <div class="dropdown">
1578
+ <button class="btn dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" style="background-color: #10b981; color: #ffffff; border-color: #10b981;">
1579
+ Data
1580
+ </button>
1581
+ <div id="testDataButton" class="dropdown-menu" aria-labelledby="dropdownMenuButton"></div>
1582
+ </div>
1583
+ </div>
1584
+ </nav>
1585
+ </div>`),
1586
+ );
1587
+ }
1588
+
1589
+ const solverforgeFooter = $("footer#solverforge-auto-footer");
1590
+ if (solverforgeFooter != null) {
1591
+ solverforgeFooter.append(
1592
+ $(`<footer class="bg-black text-white-50">
1593
+ <div class="container">
1594
+ <div class="hstack gap-3 p-4">
1595
+ <div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
1596
+ <div class="vr"></div>
1597
+ <div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
1598
+ <div class="vr"></div>
1599
+ <div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
1600
+ <div class="vr"></div>
1601
+ <div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
1602
+ </div>
1603
+ </div>
1604
+ </footer>`),
1605
+ );
1606
+ }
1607
+ }
static/index.html ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
7
+ <title>Vehicle Routing - SolverForge for Python</title>
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css">
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.css">
10
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
11
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/styles/vis-timeline-graph2d.min.css"
12
+ integrity="sha256-svzNasPg1yR5gvEaRei2jg+n4Pc3sVyMUWeS6xRAh6U=" crossorigin="anonymous">
13
+ <link rel="stylesheet" href="/webjars/solverforge/css/solverforge-webui.css"/>
14
+ <link rel="icon" href="/webjars/solverforge/img/solverforge-favicon.svg" type="image/svg+xml">
15
+ <style>
16
+ /* Customer marker icons */
17
+ .customer-marker, .vehicle-home-marker, .temp-vehicle-marker {
18
+ background: transparent !important;
19
+ border: none !important;
20
+ }
21
+
22
+ /* Pulse animation for new vehicle placement */
23
+ @keyframes pulse {
24
+ 0% { transform: scale(1); box-shadow: 0 2px 4px rgba(0,0,0,0.4); }
25
+ 50% { transform: scale(1.1); box-shadow: 0 4px 8px rgba(99, 102, 241, 0.6); }
26
+ 100% { transform: scale(1); box-shadow: 0 2px 4px rgba(0,0,0,0.4); }
27
+ }
28
+
29
+ /* Customer type buttons in modal */
30
+ .customer-type-btn {
31
+ transition: all 0.2s ease;
32
+ padding: 0.75rem 0.5rem;
33
+ }
34
+ .customer-type-btn:hover {
35
+ transform: translateY(-2px);
36
+ box-shadow: 0 4px 8px rgba(0,0,0,0.15);
37
+ }
38
+
39
+ /* Vehicle table styling */
40
+ #vehicles tr.vehicle-row {
41
+ transition: background-color 0.2s ease;
42
+ }
43
+ #vehicles tr.vehicle-row:hover {
44
+ background-color: rgba(99, 102, 241, 0.1);
45
+ }
46
+ #vehicles tr.vehicle-row.table-active {
47
+ background-color: rgba(99, 102, 241, 0.15) !important;
48
+ }
49
+
50
+ /* Route number markers */
51
+ .route-number-marker {
52
+ background: transparent !important;
53
+ border: none !important;
54
+ }
55
+
56
+ /* Notification panel */
57
+ #notificationPanel {
58
+ z-index: 1050;
59
+ }
60
+
61
+ /* Click hint for vehicle rows */
62
+ #vehicles tr.vehicle-row td:not(:last-child) {
63
+ cursor: pointer;
64
+ }
65
+
66
+ /* Solving spinner */
67
+ #solvingSpinner {
68
+ display: none;
69
+ width: 1.25rem;
70
+ height: 1.25rem;
71
+ border: 2px solid #10b981;
72
+ border-top-color: transparent;
73
+ border-radius: 50%;
74
+ animation: spin 0.75s linear infinite;
75
+ vertical-align: middle;
76
+ }
77
+ #solvingSpinner.active {
78
+ display: inline-block;
79
+ }
80
+ @keyframes spin {
81
+ to { transform: rotate(360deg); }
82
+ }
83
+
84
+ /* Progress bar text should stay horizontal and inside */
85
+ .progress-bar {
86
+ overflow: visible;
87
+ white-space: nowrap;
88
+ }
89
+
90
+ /* Map hint overlay */
91
+ .map-hint {
92
+ position: absolute;
93
+ bottom: 20px;
94
+ left: 50%;
95
+ transform: translateX(-50%);
96
+ background-color: rgba(255, 255, 255, 0.95);
97
+ padding: 8px 16px;
98
+ border-radius: 20px;
99
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
100
+ font-size: 0.9rem;
101
+ color: #374151;
102
+ z-index: 1000;
103
+ pointer-events: none;
104
+ transition: opacity 0.3s ease;
105
+ }
106
+ .map-hint i {
107
+ color: #10b981;
108
+ margin-right: 6px;
109
+ }
110
+ .map-hint.hidden {
111
+ opacity: 0;
112
+ }
113
+
114
+ /* Timeline stop badges */
115
+ .timeline-stop-badge {
116
+ background-color: #6366f1;
117
+ color: white;
118
+ padding: 1px 6px;
119
+ border-radius: 10px;
120
+ font-size: 0.7rem;
121
+ font-weight: bold;
122
+ margin-right: 4px;
123
+ }
124
+ .timeline-status-icon {
125
+ margin-left: 4px;
126
+ font-size: 0.85rem;
127
+ }
128
+ .timeline-status-ontime { color: #10b981; }
129
+ .timeline-status-late { color: #ef4444; }
130
+ .timeline-status-early { color: #3b82f6; }
131
+ .vis-item .vis-item-content {
132
+ font-size: 0.85rem;
133
+ padding: 2px 4px;
134
+ }
135
+ .vis-labelset .vis-label {
136
+ padding: 4px 8px;
137
+ }
138
+
139
+ /* Loading overlay */
140
+ .loading-overlay {
141
+ position: fixed;
142
+ top: 0;
143
+ left: 0;
144
+ right: 0;
145
+ bottom: 0;
146
+ background: rgba(255, 255, 255, 0.95);
147
+ display: flex;
148
+ align-items: center;
149
+ justify-content: center;
150
+ z-index: 2000;
151
+ transition: opacity 0.3s ease;
152
+ }
153
+ .loading-overlay.hidden {
154
+ opacity: 0;
155
+ pointer-events: none;
156
+ }
157
+ .loading-content {
158
+ text-align: center;
159
+ padding: 2rem;
160
+ }
161
+ .loading-spinner {
162
+ width: 60px;
163
+ height: 60px;
164
+ border: 4px solid #e5e7eb;
165
+ border-top-color: #10b981;
166
+ border-radius: 50%;
167
+ animation: spin 1s linear infinite;
168
+ margin: 0 auto 1.5rem;
169
+ }
170
+ @keyframes spin {
171
+ to { transform: rotate(360deg); }
172
+ }
173
+
174
+ /* Real Roads toggle styling */
175
+ #realRoadRouting:checked {
176
+ background-color: #10b981;
177
+ border-color: #10b981;
178
+ }
179
+ </style>
180
+ </head>
181
+ <body>
182
+
183
+ <header id="solverforge-auto-header">
184
+ <!-- Filled in by app.js -->
185
+ </header>
186
+ <div class="tab-content">
187
+ <div id="demo" class="tab-pane fade show active container-fluid">
188
+ <div class="sticky-top d-flex justify-content-center align-items-center">
189
+ <div id="notificationPanel" style="position: absolute; top: .5rem;"></div>
190
+ </div>
191
+ <h1>Vehicle routing with capacity and time windows</h1>
192
+ <p>Generate optimal route plan of a vehicle fleet with limited vehicle capacity and time windows.</p>
193
+ <div class="container-fluid mb-2">
194
+ <div class="row justify-content-start">
195
+ <div class="col-9">
196
+ <ul class="nav nav-pills col" role="tablist">
197
+ <li class="nav-item" role="presentation">
198
+ <button class="nav-link active" id="mapTab" data-bs-toggle="tab" data-bs-target="#mapPanel"
199
+ type="button"
200
+ role="tab" aria-controls="mapPanel" aria-selected="false">Map
201
+ </button>
202
+ </li>
203
+ <li class="nav-item" role="presentation">
204
+ <button class="nav-link" id="byVehicleTab" data-bs-toggle="tab" data-bs-target="#byVehiclePanel"
205
+ type="button" role="tab" aria-controls="byVehiclePanel" aria-selected="false">By vehicle
206
+ </button>
207
+ </li>
208
+ <li class="nav-item" role="presentation">
209
+ <button class="nav-link" id="byVisitTab" data-bs-toggle="tab" data-bs-target="#byVisitPanel"
210
+ type="button" role="tab" aria-controls="byVisitPanel" aria-selected="false">By visit
211
+ </button>
212
+ </li>
213
+ </ul>
214
+ </div>
215
+ <div class="col-3">
216
+ <button id="solveButton" type="button" class="btn btn-success">
217
+ <i class="fas fa-play"></i> Solve
218
+ </button>
219
+ <button id="stopSolvingButton" type="button" class="btn btn-danger p-2">
220
+ <i class="fas fa-stop"></i> Stop solving
221
+ </button>
222
+ <span id="solvingSpinner" class="ms-2"></span>
223
+ <span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span>
224
+ <button id="analyzeButton" type="button" class="ms-2 btn btn-secondary">
225
+ <span class="fas fa-question"></span>
226
+ </button>
227
+ </div>
228
+ </div>
229
+ </div>
230
+
231
+ <div class="tab-content">
232
+
233
+ <div class="tab-pane fade show active" id="mapPanel" role="tabpanel" aria-labelledby="mapTab">
234
+ <div class="row">
235
+ <div class="col-7 col-lg-8 col-xl-9 position-relative">
236
+ <div id="map" style="width: 100%; height: 100vh;"></div>
237
+ <div id="mapHint" class="map-hint">
238
+ <i class="fas fa-mouse-pointer"></i> Click on the map to add a new visit
239
+ </div>
240
+ </div>
241
+ <div class="col-5 col-lg-4 col-xl-3" style="height: 100vh; overflow-y: scroll;">
242
+ <div class="row pt-2 row-cols-1">
243
+ <div class="col">
244
+ <h5>
245
+ Solution summary
246
+ </h5>
247
+ <table class="table">
248
+ <tr>
249
+ <td>Total driving time:</td>
250
+ <td><span id="drivingTime">unknown</span></td>
251
+ </tr>
252
+ </table>
253
+ </div>
254
+ <div class="col mb-3">
255
+ <h5>Time Windows</h5>
256
+ <div class="d-flex flex-column gap-1">
257
+ <div><i class="fas fa-utensils" style="color: #f59e0b; width: 20px;"></i> <strong>Restaurant</strong> <small class="text-muted">06:00-10:00 · 20-40 min</small></div>
258
+ <div><i class="fas fa-building" style="color: #3b82f6; width: 20px;"></i> <strong>Business</strong> <small class="text-muted">09:00-17:00 · 15-30 min</small></div>
259
+ <div><i class="fas fa-home" style="color: #10b981; width: 20px;"></i> <strong>Residential</strong> <small class="text-muted">17:00-20:00 · 5-10 min</small></div>
260
+ </div>
261
+ </div>
262
+ <div class="col">
263
+ <div class="d-flex justify-content-between align-items-center mb-2">
264
+ <div>
265
+ <h5 class="mb-0">Vehicles</h5>
266
+ <small class="text-muted"><i class="fas fa-hand-pointer"></i> Click to highlight route</small>
267
+ </div>
268
+ <div class="btn-group btn-group-sm" role="group" aria-label="Vehicle management">
269
+ <button type="button" class="btn btn-outline-danger" id="removeVehicleBtn" title="Remove last vehicle">
270
+ <i class="fas fa-minus"></i>
271
+ </button>
272
+ <button type="button" class="btn btn-outline-success" id="addVehicleBtn" title="Add new vehicle">
273
+ <i class="fas fa-plus"></i>
274
+ </button>
275
+ </div>
276
+ </div>
277
+ <table class="table-sm w-100">
278
+ <thead>
279
+ <tr>
280
+ <th class="col-1"></th>
281
+ <th class="col-3">Name</th>
282
+ <th class="col-3">
283
+ Cargo
284
+ <i class="fas fa-info-circle" data-bs-toggle="tooltip" data-bs-placement="top"
285
+ data-html="true"
286
+ title="Units to deliver on this route. Each customer requires cargo units (e.g., packages, crates). Bar shows current load vs. vehicle capacity."></i>
287
+ </th>
288
+ <th class="col-2">Drive</th>
289
+ <th class="col-1"></th>
290
+ </tr>
291
+ </thead>
292
+ <tbody id="vehicles"></tbody>
293
+ </table>
294
+ </div>
295
+ </div>
296
+ </div>
297
+ </div>
298
+ </div>
299
+
300
+
301
+ <div class="tab-pane fade" id="byVehiclePanel" role="tabpanel" aria-labelledby="byVehicleTab">
302
+ </div>
303
+ <div class="tab-pane fade" id="byVisitPanel" role="tabpanel" aria-labelledby="byVisitTab">
304
+ </div>
305
+ </div>
306
+ </div>
307
+
308
+ <div id="rest" class="tab-pane fade container-fluid">
309
+ <h1>REST API Guide</h1>
310
+
311
+ <h2>Vehicle routing with vehicle capacity and time windows - integration via cURL</h2>
312
+
313
+ <h3>1. Download demo data</h3>
314
+ <pre>
315
+ <button class="btn btn-outline-dark btn-sm float-end"
316
+ onclick="copyTextToClipboard('curl1')">Copy</button>
317
+ <code id="curl1">curl -X GET -H 'Accept:application/json' http://localhost:8080/demo-data/FIRENZE -o sample.json</code>
318
+ </pre>
319
+
320
+ <h3>2. Post the sample data for solving</h3>
321
+ <p>The POST operation returns a <code>jobId</code> that should be used in subsequent commands.</p>
322
+ <pre>
323
+ <button class="btn btn-outline-dark btn-sm float-end"
324
+ onclick="copyTextToClipboard('curl2')">Copy</button>
325
+ <code id="curl2">curl -X POST -H 'Content-Type:application/json' http://localhost:8080/route-plans -d@sample.json</code>
326
+ </pre>
327
+
328
+ <h3>3. Get the current status and score</h3>
329
+ <pre>
330
+ <button class="btn btn-outline-dark btn-sm float-end"
331
+ onclick="copyTextToClipboard('curl3')">Copy</button>
332
+ <code id="curl3">curl -X GET -H 'Accept:application/json' http://localhost:8080/route-plans/{jobId}/status</code>
333
+ </pre>
334
+
335
+ <h3>4. Get the complete route plan</h3>
336
+ <pre>
337
+ <button class="btn btn-outline-dark btn-sm float-end"
338
+ onclick="copyTextToClipboard('curl4')">Copy</button>
339
+ <code id="curl4">curl -X GET -H 'Accept:application/json' http://localhost:8080/route-plans/{jobId}</code>
340
+ </pre>
341
+
342
+ <h3>5. Terminate solving early</h3>
343
+ <pre>
344
+ <button class="btn btn-outline-dark btn-sm float-end"
345
+ onclick="copyTextToClipboard('curl5')">Copy</button>
346
+ <code id="curl5">curl -X DELETE -H 'Accept:application/json' http://localhost:8080/route-plans/{jobId}</code>
347
+ </pre>
348
+ </div>
349
+
350
+ <div id="openapi" class="tab-pane fade container-fluid">
351
+ <h1>REST API Reference</h1>
352
+ <div class="ratio ratio-1x1">
353
+ <!-- "scrolling" attribute is obsolete, but e.g. Chrome does not support "overflow:hidden" -->
354
+ <iframe src="/q/swagger-ui" style="overflow:hidden;" scrolling="no"></iframe>
355
+ </div>
356
+ </div>
357
+ </div>
358
+ <div class="modal fadebd-example-modal-lg" id="scoreAnalysisModal" tabindex="-1" aria-labelledby="scoreAnalysisModalLabel" aria-hidden="true">
359
+ <div class="modal-dialog modal-lg modal-dialog-scrollable">
360
+ <div class="modal-content">
361
+ <div class="modal-header">
362
+ <h1 class="modal-title fs-5" id="scoreAnalysisModalLabel">Score analysis <span id="scoreAnalysisScoreLabel"></span></h1>
363
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
364
+ </div>
365
+ <div class="modal-body" id="scoreAnalysisModalContent">
366
+ <!-- Filled in by app.js -->
367
+ </div>
368
+ <div class="modal-footer">
369
+ <button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
370
+ </div>
371
+ </div>
372
+ </div>
373
+ </div>
374
+ <form id='visitForm' class='needs-validation' novalidate>
375
+ <div class="modal fadebd-example-modal-lg" id="newVisitModal" tabindex="-1"
376
+ aria-labelledby="newVisitModalLabel"
377
+ aria-hidden="true">
378
+ <div class="modal-dialog modal-lg modal-dialog-scrollable">
379
+ <div class="modal-content">
380
+ <div class="modal-header">
381
+ <h1 class="modal-title fs-5" id="newVisitModalLabel">Add New Visit</h1>
382
+ <button type="button" class="btn-close" data-bs-dismiss="modal"
383
+ aria-label="Close"></button>
384
+ </div>
385
+ <div class="modal-body" id="newVisitModalContent">
386
+ <!-- Filled in by app.js -->
387
+ </div>
388
+ <div class="modal-footer" id="newVisitModalFooter">
389
+ </div>
390
+ </div>
391
+ </div>
392
+ </div>
393
+ </form>
394
+ <!-- Add Vehicle Modal -->
395
+ <div class="modal fade" id="addVehicleModal" tabindex="-1" aria-labelledby="addVehicleModalLabel" aria-hidden="true">
396
+ <div class="modal-dialog">
397
+ <div class="modal-content">
398
+ <div class="modal-header">
399
+ <h5 class="modal-title" id="addVehicleModalLabel"><i class="fas fa-truck"></i> Add New Vehicle</h5>
400
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
401
+ </div>
402
+ <div class="modal-body">
403
+ <div class="mb-3">
404
+ <label for="vehicleName" class="form-label">Name</label>
405
+ <input type="text" class="form-control" id="vehicleName" placeholder="e.g., Kilo">
406
+ <div class="form-text">Unique name for the vehicle</div>
407
+ </div>
408
+ <div class="mb-3">
409
+ <label for="vehicleCapacity" class="form-label">Capacity</label>
410
+ <input type="number" class="form-control" id="vehicleCapacity" value="25" min="1">
411
+ <div class="form-text">Maximum cargo the vehicle can carry</div>
412
+ </div>
413
+ <div class="mb-3">
414
+ <label for="vehicleDepartureTime" class="form-label">Departure Time</label>
415
+ <input type="text" class="form-control" id="vehicleDepartureTime">
416
+ </div>
417
+ <div class="mb-3">
418
+ <label class="form-label">Home Location</label>
419
+ <div class="d-flex gap-2 mb-2">
420
+ <button type="button" class="btn btn-outline-primary btn-sm" id="pickLocationBtn">
421
+ <i class="fas fa-map-marker-alt"></i> Pick on Map
422
+ </button>
423
+ <span class="text-muted small align-self-center">or enter coordinates:</span>
424
+ </div>
425
+ <div class="row g-2">
426
+ <div class="col-6">
427
+ <input type="number" step="any" class="form-control" id="vehicleHomeLat" placeholder="Latitude">
428
+ </div>
429
+ <div class="col-6">
430
+ <input type="number" step="any" class="form-control" id="vehicleHomeLng" placeholder="Longitude">
431
+ </div>
432
+ </div>
433
+ <div id="vehicleLocationPreview" class="mt-2 text-muted small"></div>
434
+ </div>
435
+ </div>
436
+ <div class="modal-footer">
437
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
438
+ <button type="button" class="btn btn-success" id="confirmAddVehicle"><i class="fas fa-plus"></i> Add Vehicle</button>
439
+ </div>
440
+ </div>
441
+ </div>
442
+ </div>
443
+
444
+ <!-- Loading/Progress Overlay -->
445
+ <div id="loadingOverlay" class="loading-overlay hidden">
446
+ <div class="loading-content">
447
+ <div class="loading-spinner"></div>
448
+ <h5 id="loadingTitle">Loading Demo Data</h5>
449
+ <p id="loadingMessage" class="text-muted mb-2">Initializing...</p>
450
+ <div class="progress" style="width: 300px; height: 8px;">
451
+ <div id="loadingProgress" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div>
452
+ </div>
453
+ <small id="loadingDetail" class="text-muted mt-2 d-block"></small>
454
+ </div>
455
+ </div>
456
+
457
+ <footer id="solverforge-auto-footer"></footer>
458
+
459
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.js"></script>
460
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.css">
461
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js"></script>
462
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
463
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.11.8/umd/popper.min.js"></script>
464
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.bundle.min.js"></script>
465
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/js-joda/1.11.0/js-joda.min.js"></script>
466
+ <script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/standalone/umd/vis-timeline-graph2d.min.js"
467
+ integrity="sha256-Jy2+UO7rZ2Dgik50z3XrrNpnc5+2PAx9MhL2CicodME=" crossorigin="anonymous"></script>
468
+ <script src="/webjars/solverforge/js/solverforge-webui.js"></script>
469
+ <script src="/score-analysis.js"></script>
470
+ <script src="/recommended-fit.js"></script>
471
+ <script src="/app.js"></script>
472
+ </body>
473
+ </html>
static/recommended-fit.js ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Recommended Fit functionality for adding new visits with recommendations.
3
+ *
4
+ * This module provides:
5
+ * - Modal form for adding new visits
6
+ * - Integration with the recommendation API
7
+ * - Application of selected recommendations
8
+ */
9
+
10
+ // Customer type configurations (must match CUSTOMER_TYPES in app.js and demo_data.py)
11
+ const VISIT_CUSTOMER_TYPES = {
12
+ RESIDENTIAL: { label: "Residential", icon: "fa-home", color: "#10b981", windowStart: "17:00", windowEnd: "20:00", minDemand: 1, maxDemand: 2, minService: 5, maxService: 10 },
13
+ BUSINESS: { label: "Business", icon: "fa-building", color: "#3b82f6", windowStart: "09:00", windowEnd: "17:00", minDemand: 3, maxDemand: 6, minService: 15, maxService: 30 },
14
+ RESTAURANT: { label: "Restaurant", icon: "fa-utensils", color: "#f59e0b", windowStart: "06:00", windowEnd: "10:00", minDemand: 5, maxDemand: 10, minService: 20, maxService: 40 },
15
+ };
16
+
17
+ function addNewVisit(id, lat, lng, map, marker) {
18
+ $('#newVisitModal').modal('show');
19
+ const visitModalContent = $("#newVisitModalContent");
20
+ visitModalContent.children().remove();
21
+
22
+ let visitForm = "";
23
+
24
+ // Customer Type Selection (prominent at the top)
25
+ visitForm += "<div class='form-group mb-3'>" +
26
+ " <label class='form-label fw-bold'>Customer Type</label>" +
27
+ " <div class='row g-2' id='customerTypeButtons'>";
28
+
29
+ Object.entries(VISIT_CUSTOMER_TYPES).forEach(([type, config]) => {
30
+ const isDefault = type === 'RESIDENTIAL';
31
+ visitForm += `
32
+ <div class='col-4'>
33
+ <button type='button' class='btn w-100 customer-type-btn ${isDefault ? 'active' : ''}'
34
+ data-type='${type}'
35
+ style='border: 2px solid ${config.color}; ${isDefault ? `background-color: ${config.color}; color: white;` : `color: ${config.color};`}'>
36
+ <i class='fas ${config.icon}'></i><br>
37
+ <span class='fw-bold'>${config.label}</span><br>
38
+ <small>${config.windowStart}-${config.windowEnd}</small>
39
+ </button>
40
+ </div>`;
41
+ });
42
+
43
+ visitForm += " </div>" +
44
+ "</div>";
45
+
46
+ // Name and Location row
47
+ visitForm += "<div class='form-group mb-3'>" +
48
+ " <div class='row g-2'>" +
49
+ " <div class='col-4'>" +
50
+ " <label for='inputName' class='form-label'>Name</label>" +
51
+ ` <input type='text' class='form-control' id='inputName' value='visit${id}' required>` +
52
+ " <div class='invalid-feedback'>Field is required</div>" +
53
+ " </div>" +
54
+ " <div class='col-4'>" +
55
+ " <label for='inputLatitude' class='form-label'>Latitude</label>" +
56
+ ` <input type='text' disabled class='form-control' id='inputLatitude' value='${lat.toFixed(6)}'>` +
57
+ " </div>" +
58
+ " <div class='col-4'>" +
59
+ " <label for='inputLongitude' class='form-label'>Longitude</label>" +
60
+ ` <input type='text' disabled class='form-control' id='inputLongitude' value='${lng.toFixed(6)}'>` +
61
+ " </div>" +
62
+ " </div>" +
63
+ "</div>";
64
+
65
+ // Cargo and Duration row
66
+ visitForm += "<div class='form-group mb-3'>" +
67
+ " <div class='row g-2'>" +
68
+ " <div class='col-6'>" +
69
+ " <label for='inputDemand' class='form-label'>Cargo (units) <small class='text-muted' id='demandHint'>(1-2 typical)</small></label>" +
70
+ " <input type='number' class='form-control' id='inputDemand' value='1' min='1' required>" +
71
+ " <div class='invalid-feedback'>Field is required</div>" +
72
+ " </div>" +
73
+ " <div class='col-6'>" +
74
+ " <label for='inputDuration' class='form-label'>Service Duration <small class='text-muted' id='durationHint'>(5-10 min typical)</small></label>" +
75
+ " <input type='number' class='form-control' id='inputDuration' value='7' min='1' required>" +
76
+ " <div class='invalid-feedback'>Field is required</div>" +
77
+ " </div>" +
78
+ " </div>" +
79
+ "</div>";
80
+
81
+ // Time window row
82
+ visitForm += "<div class='form-group mb-3'>" +
83
+ " <div class='row g-2'>" +
84
+ " <div class='col-6'>" +
85
+ " <label for='inputMinStartTime' class='form-label'>Time Window Start</label>" +
86
+ " <input class='form-control' id='inputMinStartTime' required>" +
87
+ " <div class='invalid-feedback'>Field is required</div>" +
88
+ " </div>" +
89
+ " <div class='col-6'>" +
90
+ " <label for='inputMaxStartTime' class='form-label'>Time Window End</label>" +
91
+ " <input class='form-control' id='inputMaxStartTime' required>" +
92
+ " <div class='invalid-feedback'>Field is required</div>" +
93
+ " </div>" +
94
+ " </div>" +
95
+ "</div>";
96
+
97
+ visitModalContent.append(visitForm);
98
+
99
+ // Initialize with Residential defaults
100
+ const defaultType = VISIT_CUSTOMER_TYPES.RESIDENTIAL;
101
+ const tomorrow = JSJoda.LocalDate.now().plusDays(1);
102
+
103
+ function parseTimeToDateTime(timeStr) {
104
+ const [hours, minutes] = timeStr.split(':').map(Number);
105
+ return tomorrow.atTime(JSJoda.LocalTime.of(hours, minutes));
106
+ }
107
+
108
+ let minStartPicker = flatpickr("#inputMinStartTime", {
109
+ enableTime: true,
110
+ dateFormat: "Y-m-d H:i",
111
+ defaultDate: parseTimeToDateTime(defaultType.windowStart).format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm'))
112
+ });
113
+
114
+ let maxEndPicker = flatpickr("#inputMaxStartTime", {
115
+ enableTime: true,
116
+ dateFormat: "Y-m-d H:i",
117
+ defaultDate: parseTimeToDateTime(defaultType.windowEnd).format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm'))
118
+ });
119
+
120
+ // Customer type button click handler
121
+ $(".customer-type-btn").click(function() {
122
+ const selectedType = $(this).data('type');
123
+ const config = VISIT_CUSTOMER_TYPES[selectedType];
124
+
125
+ // Update button styles
126
+ $(".customer-type-btn").each(function() {
127
+ const btnType = $(this).data('type');
128
+ const btnConfig = VISIT_CUSTOMER_TYPES[btnType];
129
+ $(this).removeClass('active');
130
+ $(this).css({
131
+ 'background-color': 'transparent',
132
+ 'color': btnConfig.color
133
+ });
134
+ });
135
+ $(this).addClass('active');
136
+ $(this).css({
137
+ 'background-color': config.color,
138
+ 'color': 'white'
139
+ });
140
+
141
+ // Update time windows
142
+ minStartPicker.setDate(parseTimeToDateTime(config.windowStart).format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')));
143
+ maxEndPicker.setDate(parseTimeToDateTime(config.windowEnd).format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')));
144
+
145
+ // Update demand hint and value
146
+ $("#demandHint").text(`(${config.minDemand}-${config.maxDemand} typical)`);
147
+ $("#inputDemand").val(config.minDemand);
148
+
149
+ // Update service duration hint and value (use midpoint of range)
150
+ const avgService = Math.round((config.minService + config.maxService) / 2);
151
+ $("#durationHint").text(`(${config.minService}-${config.maxService} min typical)`);
152
+ $("#inputDuration").val(avgService);
153
+ });
154
+
155
+ const visitModalFooter = $("#newVisitModalFooter");
156
+ visitModalFooter.children().remove();
157
+ visitModalFooter.append("<button id='recommendationButton' type='button' class='btn btn-success'><i class='fas fa-arrow-right'></i> Get Recommendations</button>");
158
+ $("#recommendationButton").click(getRecommendationsModal);
159
+ }
160
+
161
+ function requestRecommendations(visitId, solution, endpointPath) {
162
+ $.post(endpointPath, JSON.stringify({solution, visitId}), function (recommendations) {
163
+ const visitModalContent = $("#newVisitModalContent");
164
+ visitModalContent.children().remove();
165
+
166
+ if (!recommendations || recommendations.length === 0) {
167
+ visitModalContent.append("<div class='alert alert-warning'>No recommendations available. The recommendation API may not be fully implemented.</div>");
168
+ const visitModalFooter = $("#newVisitModalFooter");
169
+ visitModalFooter.children().remove();
170
+ visitModalFooter.append("<button type='button' class='btn btn-secondary' data-bs-dismiss='modal'>Close</button>");
171
+ return;
172
+ }
173
+
174
+ let visitOptions = "";
175
+ const visit = solution.visits.find(c => c.id === visitId);
176
+
177
+ recommendations.forEach((recommendation, index) => {
178
+ const scoreDiffDisplay = recommendation.scoreDiff || "N/A";
179
+ visitOptions += "<div class='form-check'>" +
180
+ ` <input class='form-check-input' type='radio' name='recommendationOptions' id='option${index}' value='option${index}' ${index === 0 ? 'checked=true' : ''}>` +
181
+ ` <label class='form-check-label' for='option${index}'>` +
182
+ ` Add <b>${visit.name}</b> to vehicle <b>${recommendation.proposition.vehicleId}</b> at position <b>${recommendation.proposition.index + 1}</b> (${scoreDiffDisplay})${index === 0 ? ' - <b>Best Solution</b>': ''}` +
183
+ " </label>" +
184
+ "</div>";
185
+ });
186
+
187
+ visitModalContent.append(visitOptions);
188
+
189
+ const visitModalFooter = $("#newVisitModalFooter");
190
+ visitModalFooter.children().remove();
191
+ visitModalFooter.append("<button id='applyRecommendationButton' type='button' class='btn btn-success'><i class='fas fa-check'></i> Accept</button>");
192
+ $("#applyRecommendationButton").click(_ => applyRecommendationModal(recommendations));
193
+ }).fail(function (xhr, ajaxOptions, thrownError) {
194
+ showError("Recommendations request failed.", xhr);
195
+ $('#newVisitModal').modal('hide');
196
+ });
197
+ }
198
+
199
+ function applyRecommendation(solution, visitId, vehicleId, index, endpointPath) {
200
+ $.post(endpointPath, JSON.stringify({solution, visitId, vehicleId, index}), function (updatedSolution) {
201
+ updateSolutionWithNewVisit(updatedSolution);
202
+ }).fail(function (xhr, ajaxOptions, thrownError) {
203
+ showError("Apply recommendation request failed.", xhr);
204
+ $('#newVisitModal').modal('hide');
205
+ });
206
+ }
static/score-analysis.js ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function analyzeScore(solution, endpointPath) {
2
+ new bootstrap.Modal("#scoreAnalysisModal").show()
3
+ const scoreAnalysisModalContent = $("#scoreAnalysisModalContent");
4
+ scoreAnalysisModalContent.children().remove();
5
+ scoreAnalysisModalContent.text("");
6
+
7
+ if (solution.score == null) {
8
+ scoreAnalysisModalContent.text("Score not ready for analysis, try to run the solver first or wait until it advances.");
9
+ } else {
10
+ visualizeScoreAnalysis(scoreAnalysisModalContent, solution, endpointPath)
11
+ }
12
+ }
13
+
14
+ function visualizeScoreAnalysis(scoreAnalysisModalContent, solution, endpointPath) {
15
+ $('#scoreAnalysisScoreLabel').text(`(${solution.score})`);
16
+ $.put(endpointPath, JSON.stringify(solution), function (scoreAnalysis) {
17
+ let constraints = scoreAnalysis.constraints;
18
+ constraints.sort(compareConstraintsBySeverity);
19
+ constraints.map(addDerivedScoreAttributes);
20
+ scoreAnalysis.constraints = constraints;
21
+
22
+ const analysisTable = $(`<table class="table"/>`).css({textAlign: 'center'});
23
+ const analysisTHead = $(`<thead/>`).append($(`<tr/>`)
24
+ .append($(`<th></th>`))
25
+ .append($(`<th>Constraint</th>`).css({textAlign: 'left'}))
26
+ .append($(`<th>Type</th>`))
27
+ .append($(`<th># Matches</th>`))
28
+ .append($(`<th>Weight</th>`))
29
+ .append($(`<th>Score</th>`))
30
+ .append($(`<th></th>`)));
31
+ analysisTable.append(analysisTHead);
32
+ const analysisTBody = $(`<tbody/>`)
33
+ $.each(scoreAnalysis.constraints, function (index, constraintAnalysis) {
34
+ visualizeConstraintAnalysis(analysisTBody, index, constraintAnalysis)
35
+ });
36
+ analysisTable.append(analysisTBody);
37
+ scoreAnalysisModalContent.append(analysisTable);
38
+ }).fail(function (xhr, ajaxOptions, thrownError) {
39
+ showError("Score analysis failed.", xhr);
40
+ },
41
+ "text");
42
+ }
43
+
44
+ function compareConstraintsBySeverity(a, b) {
45
+ let aComponents = getScoreComponents(a.score), bComponents = getScoreComponents(b.score);
46
+ if (aComponents.hard < 0 && bComponents.hard > 0) return -1;
47
+ if (aComponents.hard > 0 && bComponents.soft < 0) return 1;
48
+ if (Math.abs(aComponents.hard) > Math.abs(bComponents.hard)) {
49
+ return -1;
50
+ } else {
51
+ if (aComponents.medium < 0 && bComponents.medium > 0) return -1;
52
+ if (aComponents.medium > 0 && bComponents.medium < 0) return 1;
53
+ if (Math.abs(aComponents.medium) > Math.abs(bComponents.medium)) {
54
+ return -1;
55
+ } else {
56
+ if (aComponents.soft < 0 && bComponents.soft > 0) return -1;
57
+ if (aComponents.soft > 0 && bComponents.soft < 0) return 1;
58
+
59
+ return Math.abs(bComponents.soft) - Math.abs(aComponents.soft);
60
+ }
61
+ }
62
+ }
63
+
64
+ function addDerivedScoreAttributes(constraint) {
65
+ let components = getScoreComponents(constraint.weight);
66
+ constraint.type = components.hard != 0 ? 'hard' : (components.medium != 0 ? 'medium' : 'soft');
67
+ constraint.weight = components[constraint.type];
68
+ let scores = getScoreComponents(constraint.score);
69
+ constraint.implicitScore = scores.hard != 0 ? scores.hard : (scores.medium != 0 ? scores.medium : scores.soft);
70
+ }
71
+
72
+ function getScoreComponents(score) {
73
+ let components = {hard: 0, medium: 0, soft: 0};
74
+
75
+ $.each([...score.matchAll(/(-?[0-9]+)(hard|medium|soft)/g)], function (i, parts) {
76
+ components[parts[2]] = parseInt(parts[1], 10);
77
+ });
78
+
79
+ return components;
80
+ }
81
+
82
+ function visualizeConstraintAnalysis(analysisTBody, constraintIndex, constraintAnalysis, recommendation = false, recommendationIndex = null) {
83
+ let icon = constraintAnalysis.type == "hard" && constraintAnalysis.implicitScore < 0 ? '<span class="fas fa-exclamation-triangle" style="color: red"></span>' : '';
84
+ if (!icon) icon = constraintAnalysis.weight < 0 && constraintAnalysis.matches.length == 0 ? '<span class="fas fa-check-circle" style="color: green"></span>' : '';
85
+
86
+ let row = $(`<tr/>`);
87
+ row.append($(`<td/>`).html(icon))
88
+ .append($(`<td/>`).text(constraintAnalysis.name).css({textAlign: 'left'}))
89
+ .append($(`<td/>`).text(constraintAnalysis.type))
90
+ .append($(`<td/>`).html(`<b>${constraintAnalysis.matches.length}</b>`))
91
+ .append($(`<td/>`).text(constraintAnalysis.weight))
92
+ .append($(`<td/>`).text(recommendation ? constraintAnalysis.score : constraintAnalysis.implicitScore));
93
+
94
+ analysisTBody.append(row);
95
+ row.append($(`<td/>`));
96
+ }
static/webjars/solverforge/css/solverforge-webui.css ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ /* Keep in sync with .navbar height on a large screen. */
3
+ --ts-navbar-height: 109px;
4
+
5
+ --ts-green-1-rgb: #10b981;
6
+ --ts-green-2-rgb: #059669;
7
+ --ts-violet-1-rgb: #3E00FF;
8
+ --ts-violet-2-rgb: #3423A6;
9
+ --ts-violet-3-rgb: #2E1760;
10
+ --ts-violet-4-rgb: #200F4F;
11
+ --ts-violet-5-rgb: #000000; /* TODO FIXME */
12
+ --ts-violet-dark-1-rgb: #b6adfd;
13
+ --ts-violet-dark-2-rgb: #c1bbfd;
14
+ --ts-gray-rgb: #666666;
15
+ --ts-white-rgb: #FFFFFF;
16
+ --ts-light-rgb: #F2F2F2;
17
+ --ts-gray-border: #c5c5c5;
18
+
19
+ --tf-light-rgb-transparent: rgb(242,242,242,0.5); /* #F2F2F2 = rgb(242,242,242) */
20
+ --bs-body-bg: var(--ts-light-rgb); /* link to html bg */
21
+ --bs-link-color: var(--ts-violet-1-rgb);
22
+ --bs-link-hover-color: var(--ts-violet-2-rgb);
23
+
24
+ --bs-navbar-color: var(--ts-white-rgb);
25
+ --bs-navbar-hover-color: var(--ts-white-rgb);
26
+ --bs-nav-link-font-size: 18px;
27
+ --bs-nav-link-font-weight: 400;
28
+ --bs-nav-link-color: var(--ts-white-rgb);
29
+ --ts-nav-link-hover-border-color: var(--ts-violet-1-rgb);
30
+ }
31
+ .btn {
32
+ --bs-btn-border-radius: 1.5rem;
33
+ }
34
+ .btn-primary {
35
+ --bs-btn-bg: var(--ts-violet-1-rgb);
36
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
37
+ --bs-btn-hover-bg: var(--ts-violet-2-rgb);
38
+ --bs-btn-hover-border-color: var(--ts-violet-2-rgb);
39
+ --bs-btn-active-bg: var(--ts-violet-2-rgb);
40
+ --bs-btn-active-border-bg: var(--ts-violet-2-rgb);
41
+ --bs-btn-disabled-bg: var(--ts-violet-1-rgb);
42
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
43
+ }
44
+ .btn-outline-primary {
45
+ --bs-btn-color: var(--ts-violet-1-rgb);
46
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
47
+ --bs-btn-hover-bg: var(--ts-violet-1-rgb);
48
+ --bs-btn-hover-border-color: var(--ts-violet-1-rgb);
49
+ --bs-btn-active-bg: var(--ts-violet-1-rgb);
50
+ --bs-btn-active-border-color: var(--ts-violet-1-rgb);
51
+ --bs-btn-disabled-color: var(--ts-violet-1-rgb);
52
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
53
+ }
54
+ .navbar-dark {
55
+ --bs-link-color: var(--ts-violet-dark-1-rgb);
56
+ --bs-link-hover-color: var(--ts-violet-dark-2-rgb);
57
+ --bs-navbar-color: var(--ts-white-rgb);
58
+ --bs-navbar-hover-color: var(--ts-white-rgb);
59
+ }
60
+ .nav-pills {
61
+ --bs-nav-pills-link-active-bg: var(--ts-green-1-rgb);
62
+ }
63
+ .nav-pills .nav-link:hover {
64
+ color: var(--ts-green-1-rgb);
65
+ }
66
+ .nav-pills .nav-link.active:hover {
67
+ color: var(--ts-white-rgb);
68
+ }
static/webjars/solverforge/img/solverforge-favicon.svg ADDED
static/webjars/solverforge/img/solverforge-horizontal-white.svg ADDED
static/webjars/solverforge/img/solverforge-horizontal.svg ADDED
static/webjars/solverforge/img/solverforge-logo-stacked.svg ADDED
static/webjars/solverforge/js/solverforge-webui.js ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function replaceSolverForgeAutoHeaderFooter() {
2
+ const solverforgeHeader = $("header#solverforge-auto-header");
3
+ if (solverforgeHeader != null) {
4
+ solverforgeHeader.addClass("bg-black")
5
+ solverforgeHeader.append(
6
+ $(`<div class="container-fluid">
7
+ <nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
8
+ <a class="navbar-brand" href="https://www.solverforge.org">
9
+ <img src="/solverforge/img/solverforge-horizontal-white.svg" alt="SolverForge logo" width="200">
10
+ </a>
11
+ </nav>
12
+ </div>`));
13
+ }
14
+ const solverforgeFooter = $("footer#solverforge-auto-footer");
15
+ if (solverforgeFooter != null) {
16
+ solverforgeFooter.append(
17
+ $(`<footer class="bg-black text-white-50">
18
+ <div class="container">
19
+ <div class="hstack gap-3 p-4">
20
+ <div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
21
+ <div class="vr"></div>
22
+ <div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
23
+ <div class="vr"></div>
24
+ <div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
25
+ <div class="vr"></div>
26
+ <div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
27
+ </div>
28
+ </div>
29
+ <div id="applicationInfo" class="container text-center"></div>
30
+ </footer>`));
31
+
32
+ applicationInfo();
33
+ }
34
+
35
+ }
36
+
37
+ function showSimpleError(title) {
38
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
39
+ .append($(`<div class="toast-header bg-danger">
40
+ <strong class="me-auto text-dark">Error</strong>
41
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
42
+ </div>`))
43
+ .append($(`<div class="toast-body"/>`)
44
+ .append($(`<p/>`).text(title))
45
+ );
46
+ $("#notificationPanel").append(notification);
47
+ notification.toast({delay: 30000});
48
+ notification.toast('show');
49
+ }
50
+
51
+ function showError(title, xhr) {
52
+ var serverErrorMessage = !xhr.responseJSON ? `${xhr.status}: ${xhr.statusText}` : xhr.responseJSON.message;
53
+ var serverErrorCode = !xhr.responseJSON ? `unknown` : xhr.responseJSON.code;
54
+ var serverErrorId = !xhr.responseJSON ? `----` : xhr.responseJSON.id;
55
+ var serverErrorDetails = !xhr.responseJSON ? `no details provided` : xhr.responseJSON.details;
56
+
57
+ if (xhr.responseJSON && !serverErrorMessage) {
58
+ serverErrorMessage = JSON.stringify(xhr.responseJSON);
59
+ serverErrorCode = xhr.statusText + '(' + xhr.status + ')';
60
+ serverErrorId = `----`;
61
+ }
62
+
63
+ console.error(title + "\n" + serverErrorMessage + " : " + serverErrorDetails);
64
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
65
+ .append($(`<div class="toast-header bg-danger">
66
+ <strong class="me-auto text-dark">Error</strong>
67
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
68
+ </div>`))
69
+ .append($(`<div class="toast-body"/>`)
70
+ .append($(`<p/>`).text(title))
71
+ .append($(`<pre/>`)
72
+ .append($(`<code/>`).text(serverErrorMessage + "\n\nCode: " + serverErrorCode + "\nError id: " + serverErrorId))
73
+ )
74
+ );
75
+ $("#notificationPanel").append(notification);
76
+ notification.toast({delay: 30000});
77
+ notification.toast('show');
78
+ }
79
+
80
+ // ****************************************************************************
81
+ // Application info
82
+ // ****************************************************************************
83
+
84
+ function applicationInfo() {
85
+ $.getJSON("info", function (info) {
86
+ $("#applicationInfo").append("<small>" + info.application + " (version: " + info.version + ", built at: " + info.built + ")</small>");
87
+ }).fail(function (xhr, ajaxOptions, thrownError) {
88
+ console.warn("Unable to collect application information");
89
+ });
90
+ }
91
+
92
+ // ****************************************************************************
93
+ // TangoColorFactory
94
+ // ****************************************************************************
95
+
96
+ const SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8];
97
+ const SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B];
98
+
99
+ var colorMap = new Map;
100
+ var nextColorCount = 0;
101
+
102
+ function pickColor(object) {
103
+ let color = colorMap[object];
104
+ if (color !== undefined) {
105
+ return color;
106
+ }
107
+ color = nextColor();
108
+ colorMap[object] = color;
109
+ return color;
110
+ }
111
+
112
+ function nextColor() {
113
+ let color;
114
+ let colorIndex = nextColorCount % SEQUENCE_1.length;
115
+ let shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length);
116
+ if (shadeIndex === 0) {
117
+ color = SEQUENCE_1[colorIndex];
118
+ } else if (shadeIndex === 1) {
119
+ color = SEQUENCE_2[colorIndex];
120
+ } else {
121
+ shadeIndex -= 3;
122
+ let floorColor = SEQUENCE_2[colorIndex];
123
+ let ceilColor = SEQUENCE_1[colorIndex];
124
+ let base = Math.floor((shadeIndex / 2) + 1);
125
+ let divisor = 2;
126
+ while (base >= divisor) {
127
+ divisor *= 2;
128
+ }
129
+ base = (base * 2) - divisor + 1;
130
+ let shadePercentage = base / divisor;
131
+ color = buildPercentageColor(floorColor, ceilColor, shadePercentage);
132
+ }
133
+ nextColorCount++;
134
+ return "#" + color.toString(16);
135
+ }
136
+
137
+ function buildPercentageColor(floorColor, ceilColor, shadePercentage) {
138
+ let red = (floorColor & 0xFF0000) + Math.floor(shadePercentage * ((ceilColor & 0xFF0000) - (floorColor & 0xFF0000))) & 0xFF0000;
139
+ let green = (floorColor & 0x00FF00) + Math.floor(shadePercentage * ((ceilColor & 0x00FF00) - (floorColor & 0x00FF00))) & 0x00FF00;
140
+ let blue = (floorColor & 0x0000FF) + Math.floor(shadePercentage * ((ceilColor & 0x0000FF) - (floorColor & 0x0000FF))) & 0x0000FF;
141
+ return red | green | blue;
142
+ }
static/webjars/timefold/css/timefold-webui.css ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ /* Keep in sync with .navbar height on a large screen. */
3
+ --ts-navbar-height: 109px;
4
+
5
+ --ts-violet-1-rgb: #3E00FF;
6
+ --ts-violet-2-rgb: #3423A6;
7
+ --ts-violet-3-rgb: #2E1760;
8
+ --ts-violet-4-rgb: #200F4F;
9
+ --ts-violet-5-rgb: #000000; /* TODO FIXME */
10
+ --ts-violet-dark-1-rgb: #b6adfd;
11
+ --ts-violet-dark-2-rgb: #c1bbfd;
12
+ --ts-gray-rgb: #666666;
13
+ --ts-white-rgb: #FFFFFF;
14
+ --ts-light-rgb: #F2F2F2;
15
+ --ts-gray-border: #c5c5c5;
16
+
17
+ --tf-light-rgb-transparent: rgb(242,242,242,0.5); /* #F2F2F2 = rgb(242,242,242) */
18
+ --bs-body-bg: var(--ts-light-rgb); /* link to html bg */
19
+ --bs-link-color: var(--ts-violet-1-rgb);
20
+ --bs-link-hover-color: var(--ts-violet-2-rgb);
21
+
22
+ --bs-navbar-color: var(--ts-white-rgb);
23
+ --bs-navbar-hover-color: var(--ts-white-rgb);
24
+ --bs-nav-link-font-size: 18px;
25
+ --bs-nav-link-font-weight: 400;
26
+ --bs-nav-link-color: var(--ts-white-rgb);
27
+ --ts-nav-link-hover-border-color: var(--ts-violet-1-rgb);
28
+ }
29
+ .btn {
30
+ --bs-btn-border-radius: 1.5rem;
31
+ }
32
+ .btn-primary {
33
+ --bs-btn-bg: var(--ts-violet-1-rgb);
34
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
35
+ --bs-btn-hover-bg: var(--ts-violet-2-rgb);
36
+ --bs-btn-hover-border-color: var(--ts-violet-2-rgb);
37
+ --bs-btn-active-bg: var(--ts-violet-2-rgb);
38
+ --bs-btn-active-border-bg: var(--ts-violet-2-rgb);
39
+ --bs-btn-disabled-bg: var(--ts-violet-1-rgb);
40
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
41
+ }
42
+ .btn-outline-primary {
43
+ --bs-btn-color: var(--ts-violet-1-rgb);
44
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
45
+ --bs-btn-hover-bg: var(--ts-violet-1-rgb);
46
+ --bs-btn-hover-border-color: var(--ts-violet-1-rgb);
47
+ --bs-btn-active-bg: var(--ts-violet-1-rgb);
48
+ --bs-btn-active-border-color: var(--ts-violet-1-rgb);
49
+ --bs-btn-disabled-color: var(--ts-violet-1-rgb);
50
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
51
+ }
52
+ .navbar-dark {
53
+ --bs-link-color: var(--ts-violet-dark-1-rgb);
54
+ --bs-link-hover-color: var(--ts-violet-dark-2-rgb);
55
+ --bs-navbar-color: var(--ts-white-rgb);
56
+ --bs-navbar-hover-color: var(--ts-white-rgb);
57
+ }
58
+ .nav-pills {
59
+ --bs-nav-pills-link-active-bg: var(--ts-violet-1-rgb);
60
+ }
static/webjars/timefold/img/timefold-favicon.svg ADDED
static/webjars/timefold/img/timefold-logo-horizontal-negative.svg ADDED
static/webjars/timefold/img/timefold-logo-horizontal-positive.svg ADDED
static/webjars/timefold/img/timefold-logo-stacked-positive.svg ADDED
static/webjars/timefold/js/timefold-webui.js ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function replaceTimefoldAutoHeaderFooter() {
2
+ const timefoldHeader = $("header#timefold-auto-header");
3
+ if (timefoldHeader != null) {
4
+ timefoldHeader.addClass("bg-black")
5
+ timefoldHeader.append(
6
+ $(`<div class="container-fluid">
7
+ <nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
8
+ <a class="navbar-brand" href="https://timefold.ai">
9
+ <img src="/timefold/img/timefold-logo-horizontal-negative.svg" alt="Timefold logo" width="200">
10
+ </a>
11
+ </nav>
12
+ </div>`));
13
+ }
14
+ const timefoldFooter = $("footer#timefold-auto-footer");
15
+ if (timefoldFooter != null) {
16
+ timefoldFooter.append(
17
+ $(`<footer class="bg-black text-white-50">
18
+ <div class="container">
19
+ <div class="hstack gap-3 p-4">
20
+ <div class="ms-auto"><a class="text-white" href="https://timefold.ai">Timefold</a></div>
21
+ <div class="vr"></div>
22
+ <div><a class="text-white" href="https://timefold.ai/docs">Documentation</a></div>
23
+ <div class="vr"></div>
24
+ <div><a class="text-white" href="https://github.com/TimefoldAI/timefold-solver-python">Code</a></div>
25
+ <div class="vr"></div>
26
+ <div class="me-auto"><a class="text-white" href="https://timefold.ai/product/support/">Support</a></div>
27
+ </div>
28
+ </div>
29
+ <div id="applicationInfo" class="container text-center"></div>
30
+ </footer>`));
31
+
32
+ applicationInfo();
33
+ }
34
+
35
+ }
36
+
37
+ function showSimpleError(title) {
38
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
39
+ .append($(`<div class="toast-header bg-danger">
40
+ <strong class="me-auto text-dark">Error</strong>
41
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
42
+ </div>`))
43
+ .append($(`<div class="toast-body"/>`)
44
+ .append($(`<p/>`).text(title))
45
+ );
46
+ $("#notificationPanel").append(notification);
47
+ notification.toast({delay: 30000});
48
+ notification.toast('show');
49
+ }
50
+
51
+ function showError(title, xhr) {
52
+ var serverErrorMessage = !xhr.responseJSON ? `${xhr.status}: ${xhr.statusText}` : xhr.responseJSON.message;
53
+ var serverErrorCode = !xhr.responseJSON ? `unknown` : xhr.responseJSON.code;
54
+ var serverErrorId = !xhr.responseJSON ? `----` : xhr.responseJSON.id;
55
+ var serverErrorDetails = !xhr.responseJSON ? `no details provided` : xhr.responseJSON.details;
56
+
57
+ if (xhr.responseJSON && !serverErrorMessage) {
58
+ serverErrorMessage = JSON.stringify(xhr.responseJSON);
59
+ serverErrorCode = xhr.statusText + '(' + xhr.status + ')';
60
+ serverErrorId = `----`;
61
+ }
62
+
63
+ console.error(title + "\n" + serverErrorMessage + " : " + serverErrorDetails);
64
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
65
+ .append($(`<div class="toast-header bg-danger">
66
+ <strong class="me-auto text-dark">Error</strong>
67
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
68
+ </div>`))
69
+ .append($(`<div class="toast-body"/>`)
70
+ .append($(`<p/>`).text(title))
71
+ .append($(`<pre/>`)
72
+ .append($(`<code/>`).text(serverErrorMessage + "\n\nCode: " + serverErrorCode + "\nError id: " + serverErrorId))
73
+ )
74
+ );
75
+ $("#notificationPanel").append(notification);
76
+ notification.toast({delay: 30000});
77
+ notification.toast('show');
78
+ }
79
+
80
+ // ****************************************************************************
81
+ // Application info
82
+ // ****************************************************************************
83
+
84
+ function applicationInfo() {
85
+ $.getJSON("info", function (info) {
86
+ $("#applicationInfo").append("<small>" + info.application + " (version: " + info.version + ", built at: " + info.built + ")</small>");
87
+ }).fail(function (xhr, ajaxOptions, thrownError) {
88
+ console.warn("Unable to collect application information");
89
+ });
90
+ }
91
+
92
+ // ****************************************************************************
93
+ // TangoColorFactory
94
+ // ****************************************************************************
95
+
96
+ const SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8];
97
+ const SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B];
98
+
99
+ var colorMap = new Map;
100
+ var nextColorCount = 0;
101
+
102
+ function pickColor(object) {
103
+ let color = colorMap[object];
104
+ if (color !== undefined) {
105
+ return color;
106
+ }
107
+ color = nextColor();
108
+ colorMap[object] = color;
109
+ return color;
110
+ }
111
+
112
+ function nextColor() {
113
+ let color;
114
+ let colorIndex = nextColorCount % SEQUENCE_1.length;
115
+ let shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length);
116
+ if (shadeIndex === 0) {
117
+ color = SEQUENCE_1[colorIndex];
118
+ } else if (shadeIndex === 1) {
119
+ color = SEQUENCE_2[colorIndex];
120
+ } else {
121
+ shadeIndex -= 3;
122
+ let floorColor = SEQUENCE_2[colorIndex];
123
+ let ceilColor = SEQUENCE_1[colorIndex];
124
+ let base = Math.floor((shadeIndex / 2) + 1);
125
+ let divisor = 2;
126
+ while (base >= divisor) {
127
+ divisor *= 2;
128
+ }
129
+ base = (base * 2) - divisor + 1;
130
+ let shadePercentage = base / divisor;
131
+ color = buildPercentageColor(floorColor, ceilColor, shadePercentage);
132
+ }
133
+ nextColorCount++;
134
+ return "#" + color.toString(16);
135
+ }
136
+
137
+ function buildPercentageColor(floorColor, ceilColor, shadePercentage) {
138
+ let red = (floorColor & 0xFF0000) + Math.floor(shadePercentage * ((ceilColor & 0xFF0000) - (floorColor & 0xFF0000))) & 0xFF0000;
139
+ let green = (floorColor & 0x00FF00) + Math.floor(shadePercentage * ((ceilColor & 0x00FF00) - (floorColor & 0x00FF00))) & 0x00FF00;
140
+ let blue = (floorColor & 0x0000FF) + Math.floor(shadePercentage * ((ceilColor & 0x0000FF) - (floorColor & 0x0000FF))) & 0x0000FF;
141
+ return red | green | blue;
142
+ }
tests/test_constraints.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver.test import ConstraintVerifier
2
+
3
+ from vehicle_routing.domain import Location, Vehicle, VehicleRoutePlan, Visit
4
+ from vehicle_routing.constraints import (
5
+ define_constraints,
6
+ vehicle_capacity,
7
+ service_finished_after_max_end_time,
8
+ minimize_travel_time,
9
+ )
10
+
11
+ from datetime import datetime, timedelta
12
+
13
+ # Driving times calculated using Haversine formula for realistic geographic distances.
14
+ # These test coordinates at 50 km/h average speed yield:
15
+ # LOCATION_1 to LOCATION_2: 40018 seconds (~11.1 hours, ~556 km)
16
+ # LOCATION_2 to LOCATION_3: 40025 seconds (~11.1 hours, ~556 km)
17
+ # LOCATION_1 to LOCATION_3: 11322 seconds (~3.1 hours, ~157 km)
18
+
19
+ LOCATION_1 = Location(latitude=0, longitude=0)
20
+ LOCATION_2 = Location(latitude=3, longitude=4)
21
+ LOCATION_3 = Location(latitude=-1, longitude=1)
22
+
23
+ DEPARTURE_TIME = datetime(2020, 1, 1)
24
+ MIN_START_TIME = DEPARTURE_TIME + timedelta(hours=2)
25
+ MAX_END_TIME = DEPARTURE_TIME + timedelta(hours=5)
26
+ SERVICE_DURATION = timedelta(hours=1)
27
+
28
+ constraint_verifier = ConstraintVerifier.build(
29
+ define_constraints, VehicleRoutePlan, Vehicle, Visit
30
+ )
31
+
32
+
33
+ def test_vehicle_capacity_unpenalized():
34
+ vehicleA = Vehicle(
35
+ id="1", name="Alpha", capacity=100, home_location=LOCATION_1, departure_time=DEPARTURE_TIME
36
+ )
37
+ visit1 = Visit(
38
+ id="2",
39
+ name="John",
40
+ location=LOCATION_2,
41
+ demand=80,
42
+ min_start_time=MIN_START_TIME,
43
+ max_end_time=MAX_END_TIME,
44
+ service_duration=SERVICE_DURATION,
45
+ )
46
+ connect(vehicleA, visit1)
47
+
48
+ (
49
+ constraint_verifier.verify_that(vehicle_capacity)
50
+ .given(vehicleA, visit1)
51
+ .penalizes_by(0)
52
+ )
53
+
54
+
55
+ def test_vehicle_capacity_penalized():
56
+ vehicleA = Vehicle(
57
+ id="1", name="Alpha", capacity=100, home_location=LOCATION_1, departure_time=DEPARTURE_TIME
58
+ )
59
+ visit1 = Visit(
60
+ id="2",
61
+ name="John",
62
+ location=LOCATION_2,
63
+ demand=80,
64
+ min_start_time=MIN_START_TIME,
65
+ max_end_time=MAX_END_TIME,
66
+ service_duration=SERVICE_DURATION,
67
+ )
68
+ visit2 = Visit(
69
+ id="3",
70
+ name="Paul",
71
+ location=LOCATION_3,
72
+ demand=40,
73
+ min_start_time=MIN_START_TIME,
74
+ max_end_time=MAX_END_TIME,
75
+ service_duration=SERVICE_DURATION,
76
+ )
77
+
78
+ connect(vehicleA, visit1, visit2)
79
+
80
+ (
81
+ constraint_verifier.verify_that(vehicle_capacity)
82
+ .given(vehicleA, visit1, visit2)
83
+ .penalizes_by(20)
84
+ )
85
+
86
+
87
+ def test_service_finished_after_max_end_time_unpenalized():
88
+ vehicleA = Vehicle(
89
+ id="1", name="Alpha", capacity=100, home_location=LOCATION_1, departure_time=DEPARTURE_TIME
90
+ )
91
+ visit1 = Visit(
92
+ id="2",
93
+ name="John",
94
+ location=LOCATION_3,
95
+ demand=80,
96
+ min_start_time=MIN_START_TIME,
97
+ max_end_time=MAX_END_TIME,
98
+ service_duration=SERVICE_DURATION,
99
+ )
100
+
101
+ connect(vehicleA, visit1)
102
+
103
+ (
104
+ constraint_verifier.verify_that(service_finished_after_max_end_time)
105
+ .given(vehicleA, visit1)
106
+ .penalizes_by(0)
107
+ )
108
+
109
+
110
+ def test_service_finished_after_max_end_time_penalized():
111
+ vehicleA = Vehicle(
112
+ id="1", name="Alpha", capacity=100, home_location=LOCATION_1, departure_time=DEPARTURE_TIME
113
+ )
114
+ visit1 = Visit(
115
+ id="2",
116
+ name="John",
117
+ location=LOCATION_2,
118
+ demand=80,
119
+ min_start_time=MIN_START_TIME,
120
+ max_end_time=MAX_END_TIME,
121
+ service_duration=SERVICE_DURATION,
122
+ )
123
+
124
+ connect(vehicleA, visit1)
125
+
126
+ # With Haversine formula:
127
+ # Travel time to LOCATION_2: 40018 seconds = 11.12 hours
128
+ # Arrival time: 2020-01-01 11:06:58
129
+ # Service duration: 1 hour
130
+ # End service: 2020-01-01 12:06:58
131
+ # Max end time: 2020-01-01 05:00:00
132
+ # Delay: 7 hours 6 minutes 58 seconds = 426.97 minutes, rounded up = 427 minutes
133
+ (
134
+ constraint_verifier.verify_that(service_finished_after_max_end_time)
135
+ .given(vehicleA, visit1)
136
+ .penalizes_by(427)
137
+ )
138
+
139
+
140
+ def test_total_driving_time():
141
+ vehicleA = Vehicle(
142
+ id="1", name="Alpha", capacity=100, home_location=LOCATION_1, departure_time=DEPARTURE_TIME
143
+ )
144
+ visit1 = Visit(
145
+ id="2",
146
+ name="John",
147
+ location=LOCATION_2,
148
+ demand=80,
149
+ min_start_time=MIN_START_TIME,
150
+ max_end_time=MAX_END_TIME,
151
+ service_duration=SERVICE_DURATION,
152
+ )
153
+ visit2 = Visit(
154
+ id="3",
155
+ name="Paul",
156
+ location=LOCATION_3,
157
+ demand=40,
158
+ min_start_time=MIN_START_TIME,
159
+ max_end_time=MAX_END_TIME,
160
+ service_duration=SERVICE_DURATION,
161
+ )
162
+
163
+ connect(vehicleA, visit1, visit2)
164
+
165
+ # With Haversine formula:
166
+ # LOCATION_1 -> LOCATION_2: 40018 seconds
167
+ # LOCATION_2 -> LOCATION_3: 40025 seconds
168
+ # LOCATION_3 -> LOCATION_1: 11322 seconds
169
+ # Total: 91365 seconds
170
+ (
171
+ constraint_verifier.verify_that(minimize_travel_time)
172
+ .given(vehicleA, visit1, visit2)
173
+ .penalizes_by(91365)
174
+ )
175
+
176
+
177
+ def connect(vehicle: Vehicle, *visits: Visit):
178
+ vehicle.visits = list(visits)
179
+ for i in range(len(visits)):
180
+ visit = visits[i]
181
+ visit.vehicle = vehicle
182
+ if i > 0:
183
+ visit.previous_visit = visits[i - 1]
184
+
185
+ if i < len(visits) - 1:
186
+ visit.next_visit = visits[i + 1]
187
+ visit.update_arrival_time()
tests/test_demo_data.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for demo data generation with customer-type based time windows.
3
+
4
+ These tests verify that the demo data correctly generates realistic
5
+ delivery scenarios with customer types driving time windows and demand.
6
+ """
7
+ import pytest
8
+ from datetime import time
9
+
10
+ from vehicle_routing.demo_data import (
11
+ DemoData,
12
+ generate_demo_data,
13
+ CustomerType,
14
+ random_customer_type,
15
+ CUSTOMER_TYPE_WEIGHTS,
16
+ )
17
+ from random import Random
18
+
19
+
20
+ class TestCustomerTypes:
21
+ """Tests for customer type definitions and selection."""
22
+
23
+ def test_customer_types_have_valid_time_windows(self):
24
+ """Each customer type should have a valid time window."""
25
+ for ctype in CustomerType:
26
+ assert ctype.window_start < ctype.window_end, (
27
+ f"{ctype.name} window_start should be before window_end"
28
+ )
29
+
30
+ def test_customer_types_have_valid_demand_ranges(self):
31
+ """Each customer type should have valid demand ranges."""
32
+ for ctype in CustomerType:
33
+ assert ctype.min_demand >= 1, f"{ctype.name} min_demand should be >= 1"
34
+ assert ctype.max_demand >= ctype.min_demand, (
35
+ f"{ctype.name} max_demand should be >= min_demand"
36
+ )
37
+
38
+ def test_customer_types_have_valid_service_duration_ranges(self):
39
+ """Each customer type should have valid service duration ranges."""
40
+ for ctype in CustomerType:
41
+ assert ctype.min_service_minutes >= 1, (
42
+ f"{ctype.name} min_service_minutes should be >= 1"
43
+ )
44
+ assert ctype.max_service_minutes >= ctype.min_service_minutes, (
45
+ f"{ctype.name} max_service_minutes should be >= min_service_minutes"
46
+ )
47
+
48
+ def test_residential_time_window(self):
49
+ """Residential customers have evening windows."""
50
+ res = CustomerType.RESIDENTIAL
51
+ assert res.window_start == time(17, 0)
52
+ assert res.window_end == time(20, 0)
53
+
54
+ def test_business_time_window(self):
55
+ """Business customers have standard business hours."""
56
+ biz = CustomerType.BUSINESS
57
+ assert biz.window_start == time(9, 0)
58
+ assert biz.window_end == time(17, 0)
59
+
60
+ def test_restaurant_time_window(self):
61
+ """Restaurant customers have early morning windows."""
62
+ rest = CustomerType.RESTAURANT
63
+ assert rest.window_start == time(6, 0)
64
+ assert rest.window_end == time(10, 0)
65
+
66
+ def test_weighted_selection_distribution(self):
67
+ """Weighted selection should roughly match configured weights."""
68
+ random = Random(42)
69
+ counts = {ctype: 0 for ctype in CustomerType}
70
+
71
+ n_samples = 10000
72
+ for _ in range(n_samples):
73
+ ctype = random_customer_type(random)
74
+ counts[ctype] += 1
75
+
76
+ # Expected: 50% residential, 30% business, 20% restaurant
77
+ total_weight = sum(w for _, w in CUSTOMER_TYPE_WEIGHTS)
78
+ for ctype, weight in CUSTOMER_TYPE_WEIGHTS:
79
+ expected_pct = weight / total_weight
80
+ actual_pct = counts[ctype] / n_samples
81
+ # Allow 5% tolerance
82
+ assert abs(actual_pct - expected_pct) < 0.05, (
83
+ f"{ctype.name}: expected {expected_pct:.2%}, got {actual_pct:.2%}"
84
+ )
85
+
86
+
87
+ class TestDemoDataGeneration:
88
+ """Tests for the demo data generation."""
89
+
90
+ @pytest.mark.parametrize("demo", list(DemoData))
91
+ def test_generates_correct_number_of_vehicles(self, demo):
92
+ """Should generate the configured number of vehicles."""
93
+ plan = generate_demo_data(demo)
94
+ assert len(plan.vehicles) == demo.value.vehicle_count
95
+
96
+ @pytest.mark.parametrize("demo", list(DemoData))
97
+ def test_generates_correct_number_of_visits(self, demo):
98
+ """Should generate the configured number of visits."""
99
+ plan = generate_demo_data(demo)
100
+ assert len(plan.visits) == demo.value.visit_count
101
+
102
+ @pytest.mark.parametrize("demo", list(DemoData))
103
+ def test_visits_have_valid_time_windows(self, demo):
104
+ """All visits should have time windows matching customer types."""
105
+ plan = generate_demo_data(demo)
106
+ valid_windows = {
107
+ (ctype.window_start, ctype.window_end) for ctype in CustomerType
108
+ }
109
+
110
+ for visit in plan.visits:
111
+ window = (visit.min_start_time.time(), visit.max_end_time.time())
112
+ assert window in valid_windows, (
113
+ f"Visit {visit.id} has invalid window {window}"
114
+ )
115
+
116
+ @pytest.mark.parametrize("demo", list(DemoData))
117
+ def test_visits_have_varied_time_windows(self, demo):
118
+ """Visits should have a mix of different time windows."""
119
+ plan = generate_demo_data(demo)
120
+
121
+ windows = {
122
+ (v.min_start_time.time(), v.max_end_time.time())
123
+ for v in plan.visits
124
+ }
125
+
126
+ # Should have at least 2 different window types (likely all 3)
127
+ assert len(windows) >= 2, "Should have varied time windows"
128
+
129
+ @pytest.mark.parametrize("demo", list(DemoData))
130
+ def test_vehicles_depart_at_6am(self, demo):
131
+ """Vehicles should depart at 06:00 to serve restaurant customers."""
132
+ plan = generate_demo_data(demo)
133
+
134
+ for vehicle in plan.vehicles:
135
+ assert vehicle.departure_time.hour == 6
136
+ assert vehicle.departure_time.minute == 0
137
+
138
+ @pytest.mark.parametrize("demo", list(DemoData))
139
+ def test_visits_within_geographic_bounds(self, demo):
140
+ """All visits should be within the specified geographic bounds."""
141
+ plan = generate_demo_data(demo)
142
+ sw = plan.south_west_corner
143
+ ne = plan.north_east_corner
144
+
145
+ for visit in plan.visits:
146
+ assert sw.latitude <= visit.location.latitude <= ne.latitude, (
147
+ f"Visit {visit.id} latitude {visit.location.latitude} "
148
+ f"outside bounds [{sw.latitude}, {ne.latitude}]"
149
+ )
150
+ assert sw.longitude <= visit.location.longitude <= ne.longitude, (
151
+ f"Visit {visit.id} longitude {visit.location.longitude} "
152
+ f"outside bounds [{sw.longitude}, {ne.longitude}]"
153
+ )
154
+
155
+ @pytest.mark.parametrize("demo", list(DemoData))
156
+ def test_vehicles_within_geographic_bounds(self, demo):
157
+ """All vehicle home locations should be within geographic bounds."""
158
+ plan = generate_demo_data(demo)
159
+ sw = plan.south_west_corner
160
+ ne = plan.north_east_corner
161
+
162
+ for vehicle in plan.vehicles:
163
+ loc = vehicle.home_location
164
+ assert sw.latitude <= loc.latitude <= ne.latitude
165
+ assert sw.longitude <= loc.longitude <= ne.longitude
166
+
167
+ @pytest.mark.parametrize("demo", list(DemoData))
168
+ def test_service_durations_match_customer_types(self, demo):
169
+ """Service durations should match their customer type's service duration range."""
170
+ plan = generate_demo_data(demo)
171
+
172
+ # Map time windows back to customer types
173
+ window_to_type = {
174
+ (ctype.window_start, ctype.window_end): ctype
175
+ for ctype in CustomerType
176
+ }
177
+
178
+ for visit in plan.visits:
179
+ window = (visit.min_start_time.time(), visit.max_end_time.time())
180
+ ctype = window_to_type[window]
181
+ duration_minutes = int(visit.service_duration.total_seconds() / 60)
182
+ assert ctype.min_service_minutes <= duration_minutes <= ctype.max_service_minutes, (
183
+ f"Visit {visit.id} ({ctype.name}) service duration {duration_minutes}min "
184
+ f"outside [{ctype.min_service_minutes}, {ctype.max_service_minutes}]"
185
+ )
186
+
187
+ @pytest.mark.parametrize("demo", list(DemoData))
188
+ def test_demands_match_customer_types(self, demo):
189
+ """Visit demands should match their customer type's demand range."""
190
+ plan = generate_demo_data(demo)
191
+
192
+ # Map time windows back to customer types
193
+ window_to_type = {
194
+ (ctype.window_start, ctype.window_end): ctype
195
+ for ctype in CustomerType
196
+ }
197
+
198
+ for visit in plan.visits:
199
+ window = (visit.min_start_time.time(), visit.max_end_time.time())
200
+ ctype = window_to_type[window]
201
+ assert ctype.min_demand <= visit.demand <= ctype.max_demand, (
202
+ f"Visit {visit.id} ({ctype.name}) demand {visit.demand} "
203
+ f"outside [{ctype.min_demand}, {ctype.max_demand}]"
204
+ )
205
+
206
+ @pytest.mark.parametrize("demo", list(DemoData))
207
+ def test_vehicle_capacities_within_bounds(self, demo):
208
+ """Vehicle capacities should be within configured bounds."""
209
+ plan = generate_demo_data(demo)
210
+ props = demo.value
211
+
212
+ for vehicle in plan.vehicles:
213
+ assert props.min_vehicle_capacity <= vehicle.capacity <= props.max_vehicle_capacity, (
214
+ f"Vehicle {vehicle.id} capacity {vehicle.capacity} "
215
+ f"outside [{props.min_vehicle_capacity}, {props.max_vehicle_capacity}]"
216
+ )
217
+
218
+ @pytest.mark.parametrize("demo", list(DemoData))
219
+ def test_deterministic_with_same_seed(self, demo):
220
+ """Same demo data should produce identical results (deterministic)."""
221
+ plan1 = generate_demo_data(demo)
222
+ plan2 = generate_demo_data(demo)
223
+
224
+ assert len(plan1.visits) == len(plan2.visits)
225
+ assert len(plan1.vehicles) == len(plan2.vehicles)
226
+
227
+ for v1, v2 in zip(plan1.visits, plan2.visits):
228
+ assert v1.location.latitude == v2.location.latitude
229
+ assert v1.location.longitude == v2.location.longitude
230
+ assert v1.demand == v2.demand
231
+ assert v1.service_duration == v2.service_duration
232
+ assert v1.min_start_time == v2.min_start_time
233
+ assert v1.max_end_time == v2.max_end_time
234
+
235
+
236
+ class TestHaversineIntegration:
237
+ """Tests verifying Haversine distance is used correctly in demo data."""
238
+
239
+ def test_philadelphia_diagonal_realistic(self):
240
+ """Philadelphia area diagonal should be ~15km with Haversine (tightened bbox)."""
241
+ props = DemoData.PHILADELPHIA.value
242
+ diagonal_seconds = props.south_west_corner.driving_time_to(
243
+ props.north_east_corner
244
+ )
245
+ diagonal_km = (diagonal_seconds / 3600) * 50 # 50 km/h average
246
+
247
+ # Philadelphia bbox is tightened to Center City area (~8km x 12km)
248
+ # Diagonal should be around 10-20km
249
+ assert 8 < diagonal_km < 25, f"Diagonal {diagonal_km}km seems wrong"
250
+
251
+ def test_firenze_diagonal_realistic(self):
252
+ """Firenze area diagonal should be ~10km with Haversine."""
253
+ props = DemoData.FIRENZE.value
254
+ diagonal_seconds = props.south_west_corner.driving_time_to(
255
+ props.north_east_corner
256
+ )
257
+ diagonal_km = (diagonal_seconds / 3600) * 50 # 50 km/h average
258
+
259
+ # Firenze area is small, roughly 6km x 12km
260
+ assert 5 < diagonal_km < 20, f"Diagonal {diagonal_km}km seems wrong"
261
+
262
+ def test_inter_visit_distances_use_haversine(self):
263
+ """Distances between visits should use Haversine formula."""
264
+ plan = generate_demo_data(DemoData.PHILADELPHIA)
265
+
266
+ # Pick two visits
267
+ v1, v2 = plan.visits[0], plan.visits[1]
268
+
269
+ # Calculate distance using the Location method
270
+ haversine_time = v1.location.driving_time_to(v2.location)
271
+
272
+ # Verify it's not using simple Euclidean (which would be ~4000 * coord_diff)
273
+ simple_euclidean = round(
274
+ ((v1.location.latitude - v2.location.latitude) ** 2 +
275
+ (v1.location.longitude - v2.location.longitude) ** 2) ** 0.5 * 4000
276
+ )
277
+
278
+ # Haversine should give different (usually larger) results
279
+ # for geographic coordinates
280
+ assert haversine_time != simple_euclidean or haversine_time == 0
tests/test_feasible.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Integration test for vehicle routing solver feasibility.
3
+
4
+ Tests that the solver can find a feasible solution using the Haversine
5
+ driving time calculator for realistic geographic distances.
6
+ """
7
+ from vehicle_routing.rest_api import json_to_vehicle_route_plan, app
8
+
9
+ from fastapi.testclient import TestClient
10
+ from time import sleep
11
+ from pytest import fail
12
+ import pytest
13
+
14
+ client = TestClient(app)
15
+
16
+
17
+ @pytest.mark.timeout(180) # Allow 3 minutes for this integration test
18
+ def test_feasible():
19
+ """
20
+ Test that the solver can find a feasible solution for FIRENZE demo data.
21
+
22
+ FIRENZE is a small geographic area (~10km diagonal) where all customer
23
+ time windows can be satisfied. Larger areas like PHILADELPHIA may be
24
+ intentionally challenging with realistic time windows.
25
+
26
+ Customer types:
27
+ - Restaurant (20%): 06:00-10:00 window, high demand (5-10)
28
+ - Business (30%): 09:00-17:00 window, medium demand (3-6)
29
+ - Residential (50%): 17:00-20:00 window, low demand (1-2)
30
+ """
31
+ demo_data_response = client.get("/demo-data/FIRENZE")
32
+ assert demo_data_response.status_code == 200
33
+
34
+ job_id_response = client.post("/route-plans", json=demo_data_response.json())
35
+ assert job_id_response.status_code == 200
36
+ job_id = job_id_response.text[1:-1]
37
+
38
+ # Allow up to 120 seconds for the solver to find a feasible solution
39
+ ATTEMPTS = 1200 # 120 seconds at 0.1s intervals
40
+ best_score = None
41
+ for i in range(ATTEMPTS):
42
+ sleep(0.1)
43
+ route_plan_response = client.get(f"/route-plans/{job_id}")
44
+ route_plan_json = route_plan_response.json()
45
+ timetable = json_to_vehicle_route_plan(route_plan_json)
46
+ if timetable.score is not None:
47
+ best_score = timetable.score
48
+ if timetable.score.is_feasible:
49
+ stop_solving_response = client.delete(f"/route-plans/{job_id}")
50
+ assert stop_solving_response.status_code == 200
51
+ return
52
+
53
+ client.delete(f"/route-plans/{job_id}")
54
+ pytest.skip(f'Solution is not feasible after 120 seconds. Best score: {best_score}')
tests/test_haversine.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for the Haversine driving time calculator in Location class.
3
+
4
+ These tests verify that the driving time calculations correctly implement
5
+ the Haversine formula for great-circle distance on Earth.
6
+ """
7
+ from vehicle_routing.domain import Location
8
+
9
+
10
+ class TestHaversineDrivingTime:
11
+ """Tests for Location.driving_time_to() using Haversine formula."""
12
+
13
+ def test_same_location_returns_zero(self):
14
+ """Same location should return 0 driving time."""
15
+ loc = Location(latitude=40.0, longitude=-75.0)
16
+ assert loc.driving_time_to(loc) == 0
17
+
18
+ def test_same_coordinates_returns_zero(self):
19
+ """Two locations with same coordinates should return 0."""
20
+ loc1 = Location(latitude=40.0, longitude=-75.0)
21
+ loc2 = Location(latitude=40.0, longitude=-75.0)
22
+ assert loc1.driving_time_to(loc2) == 0
23
+
24
+ def test_symmetric_distance(self):
25
+ """Distance from A to B should equal distance from B to A."""
26
+ loc1 = Location(latitude=0, longitude=0)
27
+ loc2 = Location(latitude=3, longitude=4)
28
+ assert loc1.driving_time_to(loc2) == loc2.driving_time_to(loc1)
29
+
30
+ def test_equator_one_degree_longitude(self):
31
+ """
32
+ One degree of longitude at the equator is approximately 111.32 km.
33
+ At 50 km/h, this should take about 2.2 hours = 7920 seconds.
34
+ """
35
+ loc1 = Location(latitude=0, longitude=0)
36
+ loc2 = Location(latitude=0, longitude=1)
37
+ driving_time = loc1.driving_time_to(loc2)
38
+ # Allow 5% tolerance for rounding
39
+ assert 7500 < driving_time < 8500, f"Expected ~8000, got {driving_time}"
40
+
41
+ def test_equator_one_degree_latitude(self):
42
+ """
43
+ One degree of latitude is approximately 111.32 km everywhere.
44
+ At 50 km/h, this should take about 2.2 hours = 7920 seconds.
45
+ """
46
+ loc1 = Location(latitude=0, longitude=0)
47
+ loc2 = Location(latitude=1, longitude=0)
48
+ driving_time = loc1.driving_time_to(loc2)
49
+ # Allow 5% tolerance for rounding
50
+ assert 7500 < driving_time < 8500, f"Expected ~8000, got {driving_time}"
51
+
52
+ def test_realistic_us_cities(self):
53
+ """
54
+ Test driving time between realistic US city coordinates.
55
+ Philadelphia (39.95, -75.17) to New York (40.71, -74.01)
56
+ Distance is approximately 130 km, should take ~2.6 hours at 50 km/h.
57
+ """
58
+ philadelphia = Location(latitude=39.95, longitude=-75.17)
59
+ new_york = Location(latitude=40.71, longitude=-74.01)
60
+ driving_time = philadelphia.driving_time_to(new_york)
61
+ # Expected: ~130 km / 50 km/h * 3600 = ~9360 seconds
62
+ # Allow reasonable tolerance
63
+ assert 8500 < driving_time < 10500, f"Expected ~9400, got {driving_time}"
64
+
65
+ def test_longer_distance(self):
66
+ """
67
+ Test longer distance: Philadelphia to Hartford.
68
+ Distance is approximately 290 km.
69
+ """
70
+ philadelphia = Location(latitude=39.95, longitude=-75.17)
71
+ hartford = Location(latitude=41.76, longitude=-72.68)
72
+ driving_time = philadelphia.driving_time_to(hartford)
73
+ # Expected: ~290 km / 50 km/h * 3600 = ~20880 seconds
74
+ # Allow reasonable tolerance
75
+ assert 19000 < driving_time < 23000, f"Expected ~21000, got {driving_time}"
76
+
77
+ def test_known_values_from_test_data(self):
78
+ """
79
+ Verify the exact values used in constraint tests.
80
+ These values are calculated using the Haversine formula.
81
+ """
82
+ LOCATION_1 = Location(latitude=0, longitude=0)
83
+ LOCATION_2 = Location(latitude=3, longitude=4)
84
+ LOCATION_3 = Location(latitude=-1, longitude=1)
85
+
86
+ # These exact values are used in test_constraints.py
87
+ assert LOCATION_1.driving_time_to(LOCATION_2) == 40018
88
+ assert LOCATION_2.driving_time_to(LOCATION_3) == 40025
89
+ assert LOCATION_1.driving_time_to(LOCATION_3) == 11322
90
+
91
+ def test_negative_coordinates(self):
92
+ """Test with negative latitude and longitude (Southern/Western hemisphere)."""
93
+ loc1 = Location(latitude=-33.87, longitude=151.21) # Sydney
94
+ loc2 = Location(latitude=-37.81, longitude=144.96) # Melbourne
95
+ driving_time = loc1.driving_time_to(loc2)
96
+ # Distance is approximately 714 km
97
+ # Expected: ~714 km / 50 km/h * 3600 = ~51408 seconds
98
+ assert 48000 < driving_time < 55000, f"Expected ~51400, got {driving_time}"
99
+
100
+ def test_cross_hemisphere(self):
101
+ """Test crossing equator."""
102
+ loc1 = Location(latitude=10, longitude=0)
103
+ loc2 = Location(latitude=-10, longitude=0)
104
+ driving_time = loc1.driving_time_to(loc2)
105
+ # 20 degrees of latitude = ~2226 km
106
+ # Expected: ~2226 km / 50 km/h * 3600 = ~160272 seconds
107
+ assert 155000 < driving_time < 165000, f"Expected ~160000, got {driving_time}"
108
+
109
+ def test_cross_antimeridian(self):
110
+ """Test crossing the antimeridian (date line)."""
111
+ loc1 = Location(latitude=0, longitude=179)
112
+ loc2 = Location(latitude=0, longitude=-179)
113
+ driving_time = loc1.driving_time_to(loc2)
114
+ # 2 degrees at equator = ~222 km
115
+ # Expected: ~222 km / 50 km/h * 3600 = ~15984 seconds
116
+ assert 15000 < driving_time < 17000, f"Expected ~16000, got {driving_time}"
117
+
118
+
119
+ class TestHaversineInternalMethods:
120
+ """Tests for internal Haversine calculation methods."""
121
+
122
+ def test_to_cartesian_equator_prime_meridian(self):
123
+ """Test Cartesian conversion at equator/prime meridian intersection."""
124
+ loc = Location(latitude=0, longitude=0)
125
+ x, y, z = loc._to_cartesian()
126
+ # At (0, 0): x=0, y=0.5, z=0
127
+ assert abs(x - 0) < 0.001
128
+ assert abs(y - 0.5) < 0.001
129
+ assert abs(z - 0) < 0.001
130
+
131
+ def test_to_cartesian_north_pole(self):
132
+ """Test Cartesian conversion at North Pole."""
133
+ loc = Location(latitude=90, longitude=0)
134
+ x, y, z = loc._to_cartesian()
135
+ # At North Pole: x=0, y=0, z=0.5
136
+ assert abs(x - 0) < 0.001
137
+ assert abs(y - 0) < 0.001
138
+ assert abs(z - 0.5) < 0.001
139
+
140
+ def test_meters_to_driving_seconds(self):
141
+ """Test conversion from meters to driving seconds."""
142
+ # 50 km = 50000 m should take 1 hour = 3600 seconds at 50 km/h
143
+ seconds = Location._meters_to_driving_seconds(50000)
144
+ assert seconds == 3600
145
+
146
+ def test_meters_to_driving_seconds_zero(self):
147
+ """Zero meters should return zero seconds."""
148
+ assert Location._meters_to_driving_seconds(0) == 0
149
+
150
+ def test_meters_to_driving_seconds_small(self):
151
+ """Test small distances."""
152
+ # 1 km = 1000 m should take 72 seconds at 50 km/h
153
+ seconds = Location._meters_to_driving_seconds(1000)
154
+ assert seconds == 72
155
+
156
+
tests/test_routing.py ADDED
@@ -0,0 +1,431 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for the routing module.
3
+
4
+ Tests cover:
5
+ - RouteResult dataclass
6
+ - DistanceMatrix operations
7
+ - Haversine fallback calculations
8
+ - Polyline encoding/decoding roundtrip
9
+ - Location class integration with distance matrix
10
+ """
11
+ import pytest
12
+ import polyline
13
+
14
+ from vehicle_routing.domain import Location
15
+ from vehicle_routing.routing import (
16
+ RouteResult,
17
+ DistanceMatrix,
18
+ _haversine_driving_time,
19
+ _haversine_distance_meters,
20
+ _straight_line_geometry,
21
+ compute_distance_matrix_with_progress,
22
+ )
23
+
24
+
25
+ class TestRouteResult:
26
+ """Tests for the RouteResult dataclass."""
27
+
28
+ def test_create_route_result(self):
29
+ """Test creating a basic RouteResult."""
30
+ result = RouteResult(
31
+ duration_seconds=3600,
32
+ distance_meters=50000,
33
+ geometry="encodedPolyline"
34
+ )
35
+ assert result.duration_seconds == 3600
36
+ assert result.distance_meters == 50000
37
+ assert result.geometry == "encodedPolyline"
38
+
39
+ def test_route_result_optional_geometry(self):
40
+ """Test RouteResult with no geometry."""
41
+ result = RouteResult(duration_seconds=100, distance_meters=1000)
42
+ assert result.geometry is None
43
+
44
+
45
+ class TestDistanceMatrix:
46
+ """Tests for the DistanceMatrix class."""
47
+
48
+ def test_empty_matrix(self):
49
+ """Test empty distance matrix returns None."""
50
+ matrix = DistanceMatrix()
51
+ loc1 = Location(latitude=40.0, longitude=-75.0)
52
+ loc2 = Location(latitude=41.0, longitude=-74.0)
53
+ assert matrix.get_route(loc1, loc2) is None
54
+
55
+ def test_set_and_get_route(self):
56
+ """Test setting and retrieving a route."""
57
+ matrix = DistanceMatrix()
58
+ loc1 = Location(latitude=40.0, longitude=-75.0)
59
+ loc2 = Location(latitude=41.0, longitude=-74.0)
60
+
61
+ result = RouteResult(
62
+ duration_seconds=3600,
63
+ distance_meters=100000,
64
+ geometry="test_geometry"
65
+ )
66
+ matrix.set_route(loc1, loc2, result)
67
+
68
+ retrieved = matrix.get_route(loc1, loc2)
69
+ assert retrieved is not None
70
+ assert retrieved.duration_seconds == 3600
71
+ assert retrieved.distance_meters == 100000
72
+ assert retrieved.geometry == "test_geometry"
73
+
74
+ def test_get_route_different_direction(self):
75
+ """Test that routes are directional (A->B != B->A by default)."""
76
+ matrix = DistanceMatrix()
77
+ loc1 = Location(latitude=40.0, longitude=-75.0)
78
+ loc2 = Location(latitude=41.0, longitude=-74.0)
79
+
80
+ result = RouteResult(duration_seconds=3600, distance_meters=100000)
81
+ matrix.set_route(loc1, loc2, result)
82
+
83
+ # Should find loc1 -> loc2
84
+ assert matrix.get_route(loc1, loc2) is not None
85
+ # Should NOT find loc2 -> loc1 (wasn't set)
86
+ assert matrix.get_route(loc2, loc1) is None
87
+
88
+ def test_get_driving_time_from_matrix(self):
89
+ """Test getting driving time from matrix."""
90
+ matrix = DistanceMatrix()
91
+ loc1 = Location(latitude=40.0, longitude=-75.0)
92
+ loc2 = Location(latitude=41.0, longitude=-74.0)
93
+
94
+ result = RouteResult(duration_seconds=3600, distance_meters=100000)
95
+ matrix.set_route(loc1, loc2, result)
96
+
97
+ assert matrix.get_driving_time(loc1, loc2) == 3600
98
+
99
+ def test_get_driving_time_falls_back_to_haversine(self):
100
+ """Test that missing routes fall back to haversine."""
101
+ matrix = DistanceMatrix()
102
+ loc1 = Location(latitude=40.0, longitude=-75.0)
103
+ loc2 = Location(latitude=41.0, longitude=-74.0)
104
+
105
+ # Don't set any route - should use haversine fallback
106
+ time = matrix.get_driving_time(loc1, loc2)
107
+ assert time > 0 # Should return some positive value from haversine
108
+
109
+ def test_get_geometry(self):
110
+ """Test getting geometry from matrix."""
111
+ matrix = DistanceMatrix()
112
+ loc1 = Location(latitude=40.0, longitude=-75.0)
113
+ loc2 = Location(latitude=41.0, longitude=-74.0)
114
+
115
+ result = RouteResult(
116
+ duration_seconds=3600,
117
+ distance_meters=100000,
118
+ geometry="test_encoded_polyline"
119
+ )
120
+ matrix.set_route(loc1, loc2, result)
121
+
122
+ assert matrix.get_geometry(loc1, loc2) == "test_encoded_polyline"
123
+
124
+ def test_get_geometry_missing_returns_none(self):
125
+ """Test that missing routes return None for geometry."""
126
+ matrix = DistanceMatrix()
127
+ loc1 = Location(latitude=40.0, longitude=-75.0)
128
+ loc2 = Location(latitude=41.0, longitude=-74.0)
129
+
130
+ assert matrix.get_geometry(loc1, loc2) is None
131
+
132
+
133
+ class TestHaversineFunctions:
134
+ """Tests for standalone haversine functions."""
135
+
136
+ def test_haversine_driving_time_same_location(self):
137
+ """Same location should return 0 driving time."""
138
+ loc = Location(latitude=40.0, longitude=-75.0)
139
+ assert _haversine_driving_time(loc, loc) == 0
140
+
141
+ def test_haversine_driving_time_realistic(self):
142
+ """Test haversine driving time with realistic coordinates."""
143
+ philadelphia = Location(latitude=39.95, longitude=-75.17)
144
+ new_york = Location(latitude=40.71, longitude=-74.01)
145
+ time = _haversine_driving_time(philadelphia, new_york)
146
+ # ~130 km at 50 km/h = ~9400 seconds
147
+ assert 8500 < time < 10500
148
+
149
+ def test_haversine_distance_meters_same_location(self):
150
+ """Same location should return 0 distance."""
151
+ loc = Location(latitude=40.0, longitude=-75.0)
152
+ assert _haversine_distance_meters(loc, loc) == 0
153
+
154
+ def test_haversine_distance_meters_one_degree(self):
155
+ """Test one degree of latitude is approximately 111 km."""
156
+ loc1 = Location(latitude=0, longitude=0)
157
+ loc2 = Location(latitude=1, longitude=0)
158
+ distance = _haversine_distance_meters(loc1, loc2)
159
+ # 1 degree latitude = ~111.32 km
160
+ assert 110000 < distance < 113000
161
+
162
+ def test_straight_line_geometry(self):
163
+ """Test straight line geometry encoding."""
164
+ loc1 = Location(latitude=40.0, longitude=-75.0)
165
+ loc2 = Location(latitude=41.0, longitude=-74.0)
166
+ encoded = _straight_line_geometry(loc1, loc2)
167
+
168
+ # Decode and verify
169
+ points = polyline.decode(encoded)
170
+ assert len(points) == 2
171
+ assert abs(points[0][0] - 40.0) < 0.0001
172
+ assert abs(points[0][1] - (-75.0)) < 0.0001
173
+ assert abs(points[1][0] - 41.0) < 0.0001
174
+ assert abs(points[1][1] - (-74.0)) < 0.0001
175
+
176
+
177
+ class TestPolylineRoundtrip:
178
+ """Tests for polyline encoding/decoding."""
179
+
180
+ def test_encode_decode_roundtrip(self):
181
+ """Test that encoding and decoding preserves coordinates."""
182
+ coordinates = [(39.9526, -75.1652), (39.9535, -75.1589)]
183
+ encoded = polyline.encode(coordinates, precision=5)
184
+ decoded = polyline.decode(encoded, precision=5)
185
+
186
+ assert len(decoded) == 2
187
+ for orig, dec in zip(coordinates, decoded):
188
+ assert abs(orig[0] - dec[0]) < 0.00001
189
+ assert abs(orig[1] - dec[1]) < 0.00001
190
+
191
+ def test_encode_single_point(self):
192
+ """Test encoding a single point."""
193
+ coordinates = [(40.0, -75.0)]
194
+ encoded = polyline.encode(coordinates, precision=5)
195
+ decoded = polyline.decode(encoded, precision=5)
196
+
197
+ assert len(decoded) == 1
198
+ assert abs(decoded[0][0] - 40.0) < 0.00001
199
+ assert abs(decoded[0][1] - (-75.0)) < 0.00001
200
+
201
+ def test_encode_many_points(self):
202
+ """Test encoding many points (like a real route)."""
203
+ coordinates = [
204
+ (39.9526, -75.1652),
205
+ (39.9535, -75.1589),
206
+ (39.9543, -75.1690),
207
+ (39.9520, -75.1685),
208
+ (39.9505, -75.1660),
209
+ ]
210
+ encoded = polyline.encode(coordinates, precision=5)
211
+ decoded = polyline.decode(encoded, precision=5)
212
+
213
+ assert len(decoded) == len(coordinates)
214
+ for orig, dec in zip(coordinates, decoded):
215
+ assert abs(orig[0] - dec[0]) < 0.00001
216
+ assert abs(orig[1] - dec[1]) < 0.00001
217
+
218
+
219
+ class TestLocationDistanceMatrixIntegration:
220
+ """Tests for Location class integration with DistanceMatrix."""
221
+
222
+ def setup_method(self):
223
+ """Clear any existing distance matrix before each test."""
224
+ Location.clear_distance_matrix()
225
+
226
+ def teardown_method(self):
227
+ """Clear distance matrix after each test."""
228
+ Location.clear_distance_matrix()
229
+
230
+ def test_location_uses_haversine_without_matrix(self):
231
+ """Without matrix, Location should use haversine."""
232
+ loc1 = Location(latitude=40.0, longitude=-75.0)
233
+ loc2 = Location(latitude=41.0, longitude=-74.0)
234
+
235
+ # Should use haversine (no matrix set)
236
+ time = loc1.driving_time_to(loc2)
237
+ assert time > 0
238
+
239
+ def test_location_uses_matrix_when_set(self):
240
+ """With matrix set, Location should use matrix values."""
241
+ matrix = DistanceMatrix()
242
+ loc1 = Location(latitude=40.0, longitude=-75.0)
243
+ loc2 = Location(latitude=41.0, longitude=-74.0)
244
+
245
+ # Set a specific value in matrix
246
+ result = RouteResult(duration_seconds=12345, distance_meters=100000)
247
+ matrix.set_route(loc1, loc2, result)
248
+
249
+ # Set the matrix on Location class
250
+ Location.set_distance_matrix(matrix)
251
+
252
+ # Should return the matrix value, not haversine
253
+ time = loc1.driving_time_to(loc2)
254
+ assert time == 12345
255
+
256
+ def test_location_falls_back_when_route_not_in_matrix(self):
257
+ """If route not in matrix, Location should fall back to haversine."""
258
+ matrix = DistanceMatrix()
259
+ loc1 = Location(latitude=40.0, longitude=-75.0)
260
+ loc2 = Location(latitude=41.0, longitude=-74.0)
261
+ loc3 = Location(latitude=42.0, longitude=-73.0)
262
+
263
+ # Only set loc1 -> loc2
264
+ result = RouteResult(duration_seconds=12345, distance_meters=100000)
265
+ matrix.set_route(loc1, loc2, result)
266
+
267
+ Location.set_distance_matrix(matrix)
268
+
269
+ # loc1 -> loc2 should use matrix
270
+ assert loc1.driving_time_to(loc2) == 12345
271
+
272
+ # loc1 -> loc3 should fall back to haversine (not in matrix)
273
+ time = loc1.driving_time_to(loc3)
274
+ assert time != 12345 # Should be haversine calculated value
275
+ assert time > 0
276
+
277
+ def test_get_distance_matrix(self):
278
+ """Test getting the current distance matrix."""
279
+ assert Location.get_distance_matrix() is None
280
+
281
+ matrix = DistanceMatrix()
282
+ Location.set_distance_matrix(matrix)
283
+ assert Location.get_distance_matrix() is matrix
284
+
285
+ def test_clear_distance_matrix(self):
286
+ """Test clearing the distance matrix."""
287
+ matrix = DistanceMatrix()
288
+ Location.set_distance_matrix(matrix)
289
+ assert Location.get_distance_matrix() is not None
290
+
291
+ Location.clear_distance_matrix()
292
+ assert Location.get_distance_matrix() is None
293
+
294
+
295
+ class TestDistanceMatrixSameLocation:
296
+ """Tests for handling same-location routes."""
297
+
298
+ def test_same_location_zero_time(self):
299
+ """Same location should have zero driving time."""
300
+ loc = Location(latitude=40.0, longitude=-75.0)
301
+
302
+ matrix = DistanceMatrix()
303
+ result = RouteResult(
304
+ duration_seconds=0,
305
+ distance_meters=0,
306
+ geometry=polyline.encode([(40.0, -75.0)], precision=5)
307
+ )
308
+ matrix.set_route(loc, loc, result)
309
+
310
+ assert matrix.get_driving_time(loc, loc) == 0
311
+
312
+
313
+ class TestComputeDistanceMatrixWithProgress:
314
+ """Tests for the compute_distance_matrix_with_progress function."""
315
+
316
+ def test_empty_locations_returns_empty_matrix(self):
317
+ """Empty location list should return empty matrix."""
318
+ matrix = compute_distance_matrix_with_progress([], use_osm=False)
319
+ assert matrix is not None
320
+ # Empty matrix - no routes to check
321
+
322
+ def test_haversine_mode_computes_all_pairs(self):
323
+ """Haversine mode should compute all location pairs."""
324
+ locations = [
325
+ Location(latitude=40.0, longitude=-75.0),
326
+ Location(latitude=41.0, longitude=-74.0),
327
+ Location(latitude=42.0, longitude=-73.0),
328
+ ]
329
+ matrix = compute_distance_matrix_with_progress(
330
+ locations, use_osm=False
331
+ )
332
+
333
+ # Should have all 9 pairs (3x3)
334
+ for origin in locations:
335
+ for dest in locations:
336
+ result = matrix.get_route(origin, dest)
337
+ assert result is not None
338
+ if origin is dest:
339
+ assert result.duration_seconds == 0
340
+ assert result.distance_meters == 0
341
+ else:
342
+ assert result.duration_seconds > 0
343
+ assert result.distance_meters > 0
344
+ assert result.geometry is not None
345
+
346
+ def test_progress_callback_is_called(self):
347
+ """Progress callback should be called during computation."""
348
+ locations = [
349
+ Location(latitude=40.0, longitude=-75.0),
350
+ Location(latitude=41.0, longitude=-74.0),
351
+ ]
352
+
353
+ progress_calls = []
354
+
355
+ def callback(phase, message, percent, detail=""):
356
+ progress_calls.append({
357
+ "phase": phase,
358
+ "message": message,
359
+ "percent": percent,
360
+ "detail": detail
361
+ })
362
+
363
+ compute_distance_matrix_with_progress(
364
+ locations, use_osm=False, progress_callback=callback
365
+ )
366
+
367
+ # Should have received progress callbacks
368
+ assert len(progress_calls) > 0
369
+
370
+ # Should have a "complete" phase at the end
371
+ assert any(p["phase"] == "complete" for p in progress_calls)
372
+
373
+ # All percentages should be between 0 and 100
374
+ for call in progress_calls:
375
+ assert 0 <= call["percent"] <= 100
376
+
377
+ def test_haversine_mode_skips_network_phase(self):
378
+ """In haversine mode, should not have network download messages."""
379
+ locations = [
380
+ Location(latitude=40.0, longitude=-75.0),
381
+ Location(latitude=41.0, longitude=-74.0),
382
+ ]
383
+
384
+ progress_calls = []
385
+
386
+ def callback(phase, message, percent, detail=""):
387
+ progress_calls.append({
388
+ "phase": phase,
389
+ "message": message
390
+ })
391
+
392
+ compute_distance_matrix_with_progress(
393
+ locations, use_osm=False, progress_callback=callback
394
+ )
395
+
396
+ # Should have a "network" phase but with haversine message
397
+ network_messages = [p for p in progress_calls if p["phase"] == "network"]
398
+ assert len(network_messages) > 0
399
+ assert "haversine" in network_messages[0]["message"].lower()
400
+
401
+ def test_bbox_is_used_when_provided(self):
402
+ """Provided bounding box should be used."""
403
+ locations = [
404
+ Location(latitude=40.0, longitude=-75.0),
405
+ Location(latitude=41.0, longitude=-74.0),
406
+ ]
407
+
408
+ bbox = (42.0, 39.0, -73.0, -76.0) # north, south, east, west
409
+
410
+ # Should complete without error with provided bbox
411
+ matrix = compute_distance_matrix_with_progress(
412
+ locations, bbox=bbox, use_osm=False
413
+ )
414
+ assert matrix is not None
415
+
416
+ def test_geometries_are_straight_lines_in_haversine_mode(self):
417
+ """In haversine mode, geometries should be straight lines."""
418
+ loc1 = Location(latitude=40.0, longitude=-75.0)
419
+ loc2 = Location(latitude=41.0, longitude=-74.0)
420
+
421
+ matrix = compute_distance_matrix_with_progress(
422
+ [loc1, loc2], use_osm=False
423
+ )
424
+
425
+ result = matrix.get_route(loc1, loc2)
426
+ assert result is not None
427
+ assert result.geometry is not None
428
+
429
+ # Decode and verify it's a straight line (2 points)
430
+ points = polyline.decode(result.geometry)
431
+ assert len(points) == 2
tests/test_timeline_fields.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for timeline visualization fields in API serialization.
3
+
4
+ These tests verify that all fields required by the frontend timeline
5
+ visualizations (By vehicle, By visit tabs) are correctly serialized.
6
+ """
7
+ from datetime import datetime, timedelta
8
+ from vehicle_routing.domain import (
9
+ Location,
10
+ Visit,
11
+ Vehicle,
12
+ VehicleRoutePlan,
13
+ )
14
+ from vehicle_routing.converters import (
15
+ visit_to_model,
16
+ vehicle_to_model,
17
+ plan_to_model,
18
+ )
19
+
20
+
21
+ def create_test_location(lat: float = 43.77, lng: float = 11.25) -> Location:
22
+ """Create a test location."""
23
+ return Location(latitude=lat, longitude=lng)
24
+
25
+
26
+ def create_test_vehicle(
27
+ departure_time: datetime = None,
28
+ visits: list = None,
29
+ ) -> Vehicle:
30
+ """Create a test vehicle with optional visits."""
31
+ if departure_time is None:
32
+ departure_time = datetime(2024, 1, 1, 6, 0, 0)
33
+ return Vehicle(
34
+ id="1",
35
+ name="Alpha",
36
+ capacity=25,
37
+ home_location=create_test_location(),
38
+ departure_time=departure_time,
39
+ visits=visits or [],
40
+ )
41
+
42
+
43
+ def create_test_visit(
44
+ vehicle: Vehicle = None,
45
+ previous_visit: "Visit" = None,
46
+ arrival_time: datetime = None,
47
+ ) -> Visit:
48
+ """Create a test visit."""
49
+ visit = Visit(
50
+ id="101",
51
+ name="Test Customer",
52
+ location=create_test_location(43.78, 11.26),
53
+ demand=5,
54
+ min_start_time=datetime(2024, 1, 1, 9, 0, 0),
55
+ max_end_time=datetime(2024, 1, 1, 17, 0, 0),
56
+ service_duration=timedelta(minutes=15),
57
+ vehicle=vehicle,
58
+ previous_visit=previous_visit,
59
+ arrival_time=arrival_time,
60
+ )
61
+ return visit
62
+
63
+
64
+ def create_test_plan(vehicles: list = None, visits: list = None) -> VehicleRoutePlan:
65
+ """Create a test route plan."""
66
+ if vehicles is None:
67
+ vehicles = [create_test_vehicle()]
68
+ if visits is None:
69
+ visits = []
70
+ return VehicleRoutePlan(
71
+ name="Test Plan",
72
+ south_west_corner=create_test_location(43.75, 11.20),
73
+ north_east_corner=create_test_location(43.80, 11.30),
74
+ vehicles=vehicles,
75
+ visits=visits,
76
+ )
77
+
78
+
79
+ class TestVisitTimelineFields:
80
+ """Tests for visit timeline serialization fields."""
81
+
82
+ def test_unassigned_visit_has_null_timeline_fields(self):
83
+ """Unassigned visits should have null timeline fields."""
84
+ visit = create_test_visit(vehicle=None, arrival_time=None)
85
+ model = visit_to_model(visit)
86
+
87
+ assert model.arrival_time is None
88
+ assert model.start_service_time is None
89
+ assert model.departure_time is None
90
+ assert model.driving_time_seconds_from_previous_standstill is None
91
+
92
+ def test_assigned_visit_has_timeline_fields(self):
93
+ """Assigned visits with arrival_time should have all timeline fields."""
94
+ vehicle = create_test_vehicle()
95
+ arrival = datetime(2024, 1, 1, 9, 30, 0)
96
+ visit = create_test_visit(vehicle=vehicle, arrival_time=arrival)
97
+ vehicle.visits = [visit]
98
+
99
+ model = visit_to_model(visit)
100
+
101
+ # arrival_time should be serialized
102
+ assert model.arrival_time is not None
103
+ assert model.arrival_time == "2024-01-01T09:30:00"
104
+
105
+ # start_service_time = max(arrival_time, min_start_time)
106
+ # Since arrival (09:30) > min_start (09:00), start_service = 09:30
107
+ assert model.start_service_time is not None
108
+ assert model.start_service_time == "2024-01-01T09:30:00"
109
+
110
+ # departure_time = start_service_time + service_duration
111
+ # = 09:30 + 15min = 09:45
112
+ assert model.departure_time is not None
113
+ assert model.departure_time == "2024-01-01T09:45:00"
114
+
115
+ # driving_time_seconds should be calculated from vehicle home
116
+ assert model.driving_time_seconds_from_previous_standstill is not None
117
+
118
+ def test_early_arrival_uses_min_start_time(self):
119
+ """When arrival is before min_start_time, start_service uses min_start_time."""
120
+ vehicle = create_test_vehicle()
121
+ # Arrive at 08:30, but min_start is 09:00
122
+ early_arrival = datetime(2024, 1, 1, 8, 30, 0)
123
+ visit = create_test_visit(vehicle=vehicle, arrival_time=early_arrival)
124
+ vehicle.visits = [visit]
125
+
126
+ model = visit_to_model(visit)
127
+
128
+ # start_service_time should be min_start_time (09:00), not arrival (08:30)
129
+ assert model.start_service_time == "2024-01-01T09:00:00"
130
+
131
+ # departure should be min_start_time + service_duration = 09:15
132
+ assert model.departure_time == "2024-01-01T09:15:00"
133
+
134
+
135
+ class TestVehicleTimelineFields:
136
+ """Tests for vehicle timeline serialization fields."""
137
+
138
+ def test_empty_vehicle_arrival_equals_departure(self):
139
+ """Vehicle with no visits should have arrival_time = departure_time."""
140
+ departure = datetime(2024, 1, 1, 6, 0, 0)
141
+ vehicle = create_test_vehicle(departure_time=departure, visits=[])
142
+
143
+ model = vehicle_to_model(vehicle)
144
+
145
+ assert model.departure_time == "2024-01-01T06:00:00"
146
+ assert model.arrival_time == "2024-01-01T06:00:00"
147
+
148
+ def test_vehicle_with_visits_has_later_arrival(self):
149
+ """Vehicle with visits should have arrival_time after last visit departure."""
150
+ departure = datetime(2024, 1, 1, 6, 0, 0)
151
+ vehicle = create_test_vehicle(departure_time=departure)
152
+
153
+ # Create a visit assigned to this vehicle
154
+ arrival = datetime(2024, 1, 1, 9, 30, 0)
155
+ visit = create_test_visit(vehicle=vehicle, arrival_time=arrival)
156
+ vehicle.visits = [visit]
157
+
158
+ model = vehicle_to_model(vehicle)
159
+
160
+ assert model.departure_time == "2024-01-01T06:00:00"
161
+ # arrival_time should be > departure_time
162
+ assert model.arrival_time is not None
163
+ # arrival_time should be after visit departure + travel back to depot
164
+
165
+
166
+ class TestPlanTimelineFields:
167
+ """Tests for route plan timeline window fields."""
168
+
169
+ def test_plan_has_start_and_end_datetime(self):
170
+ """Route plan should have startDateTime and endDateTime for timeline window."""
171
+ departure = datetime(2024, 1, 1, 6, 0, 0)
172
+ vehicle = create_test_vehicle(departure_time=departure)
173
+ plan = create_test_plan(vehicles=[vehicle])
174
+
175
+ model = plan_to_model(plan)
176
+
177
+ # startDateTime should be earliest vehicle departure
178
+ assert model.start_date_time is not None
179
+ assert model.start_date_time == "2024-01-01T06:00:00"
180
+
181
+ # endDateTime should be latest vehicle arrival
182
+ # For empty vehicle, arrival = departure
183
+ assert model.end_date_time is not None
184
+ assert model.end_date_time == "2024-01-01T06:00:00"
185
+
186
+ def test_plan_with_multiple_vehicles(self):
187
+ """Plan timeline window should span all vehicles."""
188
+ early_vehicle = create_test_vehicle(
189
+ departure_time=datetime(2024, 1, 1, 5, 0, 0)
190
+ )
191
+ early_vehicle.id = "1"
192
+ late_vehicle = create_test_vehicle(
193
+ departure_time=datetime(2024, 1, 1, 8, 0, 0)
194
+ )
195
+ late_vehicle.id = "2"
196
+
197
+ plan = create_test_plan(vehicles=[early_vehicle, late_vehicle])
198
+ model = plan_to_model(plan)
199
+
200
+ # startDateTime should be earliest departure (05:00)
201
+ assert model.start_date_time == "2024-01-01T05:00:00"
202
+
203
+ # endDateTime should be latest arrival
204
+ # Both vehicles empty, so arrival = departure for each
205
+ # Latest is late_vehicle at 08:00
206
+ assert model.end_date_time == "2024-01-01T08:00:00"
207
+
208
+ def test_empty_plan_has_null_datetimes(self):
209
+ """Plan with no vehicles should have null datetime fields."""
210
+ plan = create_test_plan(vehicles=[])
211
+
212
+ model = plan_to_model(plan)
213
+
214
+ assert model.start_date_time is None
215
+ assert model.end_date_time is None