blackopsrepl commited on
Commit
4ac26b7
·
verified ·
1 Parent(s): eac31dd

Delete src

Browse files
src/vehicle_routing/__init__.py DELETED
@@ -1,16 +0,0 @@
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 DELETED
@@ -1,105 +0,0 @@
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 DELETED
@@ -1,228 +0,0 @@
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 DELETED
@@ -1,478 +0,0 @@
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 DELETED
@@ -1,371 +0,0 @@
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 DELETED
@@ -1,85 +0,0 @@
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 DELETED
@@ -1,524 +0,0 @@
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 DELETED
@@ -1,622 +0,0 @@
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 DELETED
@@ -1,20 +0,0 @@
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 DELETED
@@ -1,23 +0,0 @@
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)