blackopsrepl commited on
Commit
784416b
·
verified ·
1 Parent(s): 1b53ebe

Upload 66 files

Browse files
Files changed (43) hide show
  1. pom.xml +1 -0
  2. pyproject.toml +4 -0
  3. src/vehicle_routing/__init__.py +1 -2
  4. src/vehicle_routing/__pycache__/__init__.cpython-310.pyc +0 -0
  5. src/vehicle_routing/__pycache__/__init__.cpython-312.pyc +0 -0
  6. src/vehicle_routing/__pycache__/constraints.cpython-310.pyc +0 -0
  7. src/vehicle_routing/__pycache__/constraints.cpython-312.pyc +0 -0
  8. src/vehicle_routing/__pycache__/converters.cpython-310.pyc +0 -0
  9. src/vehicle_routing/__pycache__/converters.cpython-312.pyc +0 -0
  10. src/vehicle_routing/__pycache__/demo_data.cpython-310.pyc +0 -0
  11. src/vehicle_routing/__pycache__/demo_data.cpython-312.pyc +0 -0
  12. src/vehicle_routing/__pycache__/domain.cpython-310.pyc +0 -0
  13. src/vehicle_routing/__pycache__/domain.cpython-312.pyc +0 -0
  14. src/vehicle_routing/__pycache__/json_serialization.cpython-310.pyc +0 -0
  15. src/vehicle_routing/__pycache__/json_serialization.cpython-312.pyc +0 -0
  16. src/vehicle_routing/__pycache__/rest_api.cpython-310.pyc +0 -0
  17. src/vehicle_routing/__pycache__/rest_api.cpython-312.pyc +0 -0
  18. src/vehicle_routing/__pycache__/routing.cpython-312.pyc +0 -0
  19. src/vehicle_routing/__pycache__/score_analysis.cpython-310.pyc +0 -0
  20. src/vehicle_routing/__pycache__/score_analysis.cpython-312.pyc +0 -0
  21. src/vehicle_routing/__pycache__/solver.cpython-310.pyc +0 -0
  22. src/vehicle_routing/__pycache__/solver.cpython-312.pyc +0 -0
  23. src/vehicle_routing/demo_data.py +328 -62
  24. src/vehicle_routing/domain.py +28 -57
  25. src/vehicle_routing/rest_api.py +292 -9
  26. src/vehicle_routing/routing.py +622 -0
  27. static/app.js +236 -22
  28. static/index.html +55 -1
  29. tests/.pytest_cache/.gitignore +2 -0
  30. tests/.pytest_cache/CACHEDIR.TAG +4 -0
  31. tests/.pytest_cache/README.md +8 -0
  32. tests/.pytest_cache/v/cache/lastfailed +4 -0
  33. tests/.pytest_cache/v/cache/nodeids +1 -0
  34. tests/.pytest_cache/v/cache/stepwise +1 -0
  35. tests/__pycache__/test_constraints.cpython-312-pytest-8.2.2.pyc +0 -0
  36. tests/__pycache__/test_demo_data.cpython-312-pytest-8.2.2.pyc +0 -0
  37. tests/__pycache__/test_feasible.cpython-312-pytest-8.2.2.pyc +0 -0
  38. tests/__pycache__/test_haversine.cpython-312-pytest-8.2.2.pyc +0 -0
  39. tests/__pycache__/test_routing.cpython-312-pytest-8.2.2.pyc +0 -0
  40. tests/__pycache__/test_timeline_fields.cpython-312-pytest-8.2.2.pyc +0 -0
  41. tests/test_demo_data.py +4 -4
  42. tests/test_haversine.py +1 -100
  43. tests/test_routing.py +431 -0
pom.xml ADDED
@@ -0,0 +1 @@
 
 
1
+ <?xml version="1.0" encoding="utf-8"?>
pyproject.toml CHANGED
@@ -13,6 +13,10 @@ dependencies = [
13
  'pydantic == 2.7.3',
14
  'uvicorn == 0.30.1',
15
  'pytest == 8.2.2',
 
 
 
 
16
  ]
17
 
18
 
 
13
  'pydantic == 2.7.3',
14
  'uvicorn == 0.30.1',
15
  'pytest == 8.2.2',
16
+ 'osmnx >= 1.9.0',
17
+ 'networkx >= 3.0',
18
+ 'polyline >= 2.0.0',
19
+ 'scikit-learn >= 1.0.0',
20
  ]
21
 
22
 
src/vehicle_routing/__init__.py CHANGED
@@ -5,8 +5,7 @@ from .rest_api import app as app
5
 
6
  def main():
7
  config = uvicorn.Config("vehicle_routing:app",
8
- host="0.0.0.0",
9
- port=8080,
10
  log_config="logging.conf",
11
  use_colors=True)
12
  server = uvicorn.Server(config)
 
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)
src/vehicle_routing/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (544 Bytes). View file
 
src/vehicle_routing/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (715 Bytes). View file
 
src/vehicle_routing/__pycache__/constraints.cpython-310.pyc ADDED
Binary file (1.97 kB). View file
 
src/vehicle_routing/__pycache__/constraints.cpython-312.pyc ADDED
Binary file (4.87 kB). View file
 
src/vehicle_routing/__pycache__/converters.cpython-310.pyc ADDED
Binary file (4.9 kB). View file
 
src/vehicle_routing/__pycache__/converters.cpython-312.pyc ADDED
Binary file (10.9 kB). View file
 
src/vehicle_routing/__pycache__/demo_data.cpython-310.pyc ADDED
Binary file (5.38 kB). View file
 
src/vehicle_routing/__pycache__/demo_data.cpython-312.pyc ADDED
Binary file (23.8 kB). View file
 
src/vehicle_routing/__pycache__/domain.cpython-310.pyc ADDED
Binary file (8.6 kB). View file
 
src/vehicle_routing/__pycache__/domain.cpython-312.pyc ADDED
Binary file (19 kB). View file
 
src/vehicle_routing/__pycache__/json_serialization.cpython-310.pyc ADDED
Binary file (2.92 kB). View file
 
src/vehicle_routing/__pycache__/json_serialization.cpython-312.pyc ADDED
Binary file (3.99 kB). View file
 
src/vehicle_routing/__pycache__/rest_api.cpython-310.pyc ADDED
Binary file (3.84 kB). View file
 
src/vehicle_routing/__pycache__/rest_api.cpython-312.pyc ADDED
Binary file (23.8 kB). View file
 
src/vehicle_routing/__pycache__/routing.cpython-312.pyc ADDED
Binary file (23.6 kB). View file
 
src/vehicle_routing/__pycache__/score_analysis.cpython-310.pyc ADDED
Binary file (897 Bytes). View file
 
src/vehicle_routing/__pycache__/score_analysis.cpython-312.pyc ADDED
Binary file (1.14 kB). View file
 
src/vehicle_routing/__pycache__/solver.cpython-310.pyc ADDED
Binary file (826 Bytes). View file
 
src/vehicle_routing/__pycache__/solver.cpython-312.pyc ADDED
Binary file (1.04 kB). View file
 
src/vehicle_routing/demo_data.py CHANGED
@@ -1,15 +1,210 @@
1
- from typing import Generator, TypeVar, Sequence
2
  from datetime import date, datetime, time, timedelta
3
  from enum import Enum
4
  from random import Random
5
- from dataclasses import dataclass
6
 
7
- from .domain import Location, Vehicle, VehicleRoutePlan, Visit, init_driving_time_matrix, clear_driving_time_matrix
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
  # Vehicle names using phonetic alphabet for clear identification
14
  VEHICLE_NAMES = ("Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel", "India", "Juliet")
15
 
@@ -91,26 +286,32 @@ class _DemoDataProperties:
91
 
92
 
93
  class DemoData(Enum):
 
 
 
 
94
  PHILADELPHIA = _DemoDataProperties(0, 55, 6, time(6, 0),
95
  15, 30,
96
- Location(latitude=39.7656099067391,
97
- longitude=-76.83782328143754),
98
- Location(latitude=40.77636644354855,
99
- longitude=-74.9300739430771))
100
 
 
101
  HARTFORT = _DemoDataProperties(1, 50, 6, time(6, 0),
102
  20, 30,
103
- Location(latitude=41.48366520850297,
104
- longitude=-73.15901689943055),
105
- Location(latitude=41.99512052869307,
106
- longitude=-72.25114548877427))
107
 
 
108
  FIRENZE = _DemoDataProperties(2, 77, 6, time(6, 0),
109
  20, 40,
110
- Location(latitude=43.751466,
111
- longitude=11.177210),
112
- Location(latitude=43.809291,
113
- longitude=11.290195))
114
 
115
 
116
  def doubles(random: Random, start: float, end: float) -> Generator[float, None, None]:
@@ -138,74 +339,139 @@ def generate_names(random: Random) -> Generator[str, None, None]:
138
  yield f'{random.choice(FIRST_NAMES)} {random.choice(LAST_NAMES)}'
139
 
140
 
141
- def generate_demo_data(demo_data_enum: DemoData, use_precomputed_matrix: bool = False) -> VehicleRoutePlan:
142
  """
143
- Generate demo data for vehicle routing.
144
 
145
- Creates a realistic delivery scenario with three customer types:
146
  - Residential (50%): Evening windows (17:00-20:00), small orders (1-2 units)
147
  - Business (30%): Business hours (09:00-17:00), medium orders (3-6 units)
148
  - Restaurant (20%): Early morning (06:00-10:00), large orders (5-10 units)
149
 
150
  Args:
151
  demo_data_enum: The demo data configuration to use
152
- use_precomputed_matrix: If True, pre-compute driving time matrix for O(1) lookups.
153
- If False (default), calculate distances on-demand.
154
- Pre-computed is faster during solving but uses O(n²) memory.
155
  """
156
  name = "demo"
157
  demo_data = demo_data_enum.value
158
  random = Random(demo_data.seed)
159
- latitudes = doubles(random, demo_data.south_west_corner.latitude, demo_data.north_east_corner.latitude)
160
- longitudes = doubles(random, demo_data.south_west_corner.longitude, demo_data.north_east_corner.longitude)
 
161
 
162
  vehicle_capacities = ints(random, demo_data.min_vehicle_capacity,
163
  demo_data.max_vehicle_capacity + 1)
164
 
165
- vehicles = [Vehicle(id=str(i),
166
- name=VEHICLE_NAMES[i % len(VEHICLE_NAMES)],
167
- capacity=next(vehicle_capacities),
168
- home_location=Location(
169
- latitude=next(latitudes),
170
- longitude=next(longitudes)),
171
- departure_time=datetime.combine(
172
- date.today() + timedelta(days=1), demo_data.vehicle_start_time)
173
- )
174
- for i in range(demo_data.vehicle_count)]
175
-
176
- names = generate_names(random)
177
- tomorrow = date.today() + timedelta(days=1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
 
 
179
  visits = []
180
- for i in range(demo_data.visit_count):
181
- ctype = random_customer_type(random)
182
- service_minutes = random.randint(ctype.min_service_minutes, ctype.max_service_minutes)
183
- visits.append(
184
- Visit(
185
- id=str(i),
186
- name=next(names),
187
- location=Location(latitude=next(latitudes), longitude=next(longitudes)),
188
- demand=random.randint(ctype.min_demand, ctype.max_demand),
189
- min_start_time=datetime.combine(tomorrow, ctype.window_start),
190
- max_end_time=datetime.combine(tomorrow, ctype.window_end),
191
- service_duration=timedelta(minutes=service_minutes),
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  )
193
- )
194
 
195
- # Handle driving time calculation mode
196
- if use_precomputed_matrix:
197
- # Pre-compute driving time matrix for faster solving
198
- all_locations = [v.home_location for v in vehicles] + [v.location for v in visits]
199
- init_driving_time_matrix(all_locations)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  else:
201
- # Clear any existing pre-computed matrix to ensure on-demand calculation
202
- clear_driving_time_matrix()
203
-
204
- return VehicleRoutePlan(name=name,
205
- south_west_corner=demo_data.south_west_corner,
206
- north_east_corner=demo_data.north_east_corner,
207
- vehicles=vehicles,
208
- visits=visits)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
 
211
  def tomorrow_at(local_time: time) -> datetime:
 
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
 
 
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]:
 
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:
src/vehicle_routing/domain.py CHANGED
@@ -15,21 +15,13 @@ from solverforge_legacy.solver.domain import (
15
  )
16
 
17
  from datetime import datetime, timedelta
18
- from typing import Annotated, Optional, List, Union
19
  from dataclasses import dataclass, field
20
  from .json_serialization import JsonDomainBase
21
  from pydantic import Field
22
 
23
-
24
- # Global driving time matrix for pre-computed mode
25
- # Key: (from_lat, from_lng, to_lat, to_lng) -> driving_time_seconds
26
- # This is kept outside the Location class to avoid transpiler issues with mutable fields
27
- _DRIVING_TIME_MATRIX: dict[tuple[float, float, float, float], int] = {}
28
-
29
-
30
- def _get_matrix_key(from_loc: "Location", to_loc: "Location") -> tuple[float, float, float, float]:
31
- """Create a hashable key for the driving time matrix lookup."""
32
- return (from_loc.latitude, from_loc.longitude, to_loc.latitude, to_loc.longitude)
33
 
34
 
35
  @dataclass
@@ -37,38 +29,46 @@ class Location:
37
  """
38
  Represents a geographic location with latitude and longitude.
39
 
40
- Driving times can be calculated in two modes:
41
- 1. On-demand (default): Uses Haversine formula for each calculation
42
- 2. Pre-computed matrix: O(1) lookup from global pre-calculated distance matrix
43
-
44
- The pre-computed mode is faster during solving (millions of lookups)
45
- but requires O(n²) memory and one-time initialization cost.
46
-
47
- To enable pre-computed mode, call init_driving_time_matrix() with all locations
48
- before solving.
49
  """
50
  latitude: float
51
  longitude: float
52
 
 
 
 
53
  # Earth radius in meters
54
  _EARTH_RADIUS_M = 6371000
55
  _TWICE_EARTH_RADIUS_M = 2 * _EARTH_RADIUS_M
56
  # Average driving speed assumption: 50 km/h
57
  _AVERAGE_SPEED_KMPH = 50
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  def driving_time_to(self, other: "Location") -> int:
60
  """
61
  Get driving time in seconds to another location.
62
 
63
- If a pre-computed matrix is available (via init_driving_time_matrix),
64
- uses O(1) lookup. Otherwise, calculates on-demand using Haversine formula.
65
  """
66
- # Use pre-computed matrix if available
67
- key = _get_matrix_key(self, other)
68
- if key in _DRIVING_TIME_MATRIX:
69
- return _DRIVING_TIME_MATRIX[key]
70
-
71
- # Fall back to on-demand calculation
72
  return self._calculate_driving_time_haversine(other)
73
 
74
  def _calculate_driving_time_haversine(self, other: "Location") -> int:
@@ -124,35 +124,6 @@ class Location:
124
  return f"Location({self.latitude}, {self.longitude})"
125
 
126
 
127
- def init_driving_time_matrix(locations: list[Location]) -> None:
128
- """
129
- Pre-compute driving times between all location pairs.
130
-
131
- This trades O(n²) memory for O(1) lookup during solving.
132
- For n=77 locations (FIRENZE), this is only 5,929 entries.
133
-
134
- Call this once after creating all locations but before solving.
135
- The matrix is stored globally and persists across solver runs.
136
- """
137
- global _DRIVING_TIME_MATRIX
138
- _DRIVING_TIME_MATRIX = {}
139
- for from_loc in locations:
140
- for to_loc in locations:
141
- key = _get_matrix_key(from_loc, to_loc)
142
- _DRIVING_TIME_MATRIX[key] = from_loc._calculate_driving_time_haversine(to_loc)
143
-
144
-
145
- def clear_driving_time_matrix() -> None:
146
- """Clear the pre-computed driving time matrix."""
147
- global _DRIVING_TIME_MATRIX
148
- _DRIVING_TIME_MATRIX = {}
149
-
150
-
151
- def is_driving_time_matrix_initialized() -> bool:
152
- """Check if the driving time matrix has been pre-computed."""
153
- return len(_DRIVING_TIME_MATRIX) > 0
154
-
155
-
156
  @planning_entity
157
  @dataclass
158
  class Visit:
 
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
 
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:
 
124
  return f"Location({self.latitude}, {self.longitude})"
125
 
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  @planning_entity
128
  @dataclass
129
  class Visit:
src/vehicle_routing/rest_api.py CHANGED
@@ -1,17 +1,31 @@
1
- from fastapi import FastAPI, HTTPException
2
  from fastapi.staticfiles import StaticFiles
 
3
  from uuid import uuid4
4
- from typing import Dict, List
5
  from dataclasses import asdict
 
 
 
 
6
 
7
- from .domain import VehicleRoutePlan
8
  from .converters import plan_to_model, model_to_plan
9
  from .domain import VehicleRoutePlanModel
10
  from .score_analysis import ConstraintAnalysisDTO, MatchAnalysisDTO
11
  from .demo_data import generate_demo_data, DemoData
12
  from .solver import solver_manager, solution_manager
 
13
  from pydantic import BaseModel, Field
14
 
 
 
 
 
 
 
 
 
15
  app = FastAPI(docs_url='/q/swagger-ui')
16
 
17
  data_sets: Dict[str, VehicleRoutePlan] = {}
@@ -67,25 +81,240 @@ async def get_demo_data():
67
  """Get available demo data sets."""
68
  return [demo.name for demo in DemoData]
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  @app.get("/demo-data/{demo_name}", response_model=VehicleRoutePlanModel)
71
- async def get_demo_data_by_name(demo_name: str, distanceMode: str = "ON_DEMAND") -> VehicleRoutePlanModel:
 
 
 
 
 
 
72
  """
73
  Get a specific demo data set.
74
 
75
  Args:
76
  demo_name: Name of the demo dataset (PHILADELPHIA, HARTFORT, FIRENZE)
77
- distanceMode: Distance calculation mode:
78
- - ON_DEMAND: Calculate distances using Haversine formula on each call (default)
79
- - PRECOMPUTED: Pre-compute distance matrix for O(1) lookups (faster solving)
 
 
80
  """
81
  try:
82
  demo_data = DemoData[demo_name]
83
- use_precomputed = distanceMode == "PRECOMPUTED"
84
- domain_plan = generate_demo_data(demo_data, use_precomputed_matrix=use_precomputed)
 
 
 
 
85
  return plan_to_model(domain_plan)
86
  except KeyError:
87
  raise HTTPException(status_code=404, detail=f"Demo data '{demo_name}' not found")
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  @app.get("/route-plans/{problem_id}", response_model=VehicleRoutePlanModel, response_model_exclude_none=True)
90
  async def get_route(problem_id: str) -> VehicleRoutePlanModel:
91
  route = data_sets.get(problem_id)
@@ -238,4 +467,58 @@ async def apply_recommendation(request: ApplyRecommendationRequest) -> VehicleRo
238
  return plan_to_model(domain_plan)
239
 
240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  app.mount("/", StaticFiles(directory="static", html=True), name="static")
 
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] = {}
 
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)
 
467
  return plan_to_model(domain_plan)
468
 
469
 
470
+ class RouteGeometryResponse(BaseModel):
471
+ """Response containing encoded polyline geometries for all vehicle routes."""
472
+ geometries: Dict[str, List[Optional[str]]]
473
+
474
+
475
+ @app.get("/route-plans/{problem_id}/geometry", response_model=RouteGeometryResponse)
476
+ async def get_route_geometry(problem_id: str) -> RouteGeometryResponse:
477
+ """
478
+ Get route geometries for all vehicle routes in a problem.
479
+
480
+ Returns encoded polylines (Google polyline format) for each route segment.
481
+ Each vehicle's route is represented as a list of encoded polylines:
482
+ - First segment: depot -> first visit
483
+ - Middle segments: visit -> visit
484
+ - Last segment: last visit -> depot
485
+
486
+ These can be decoded on the frontend to display actual road routes
487
+ instead of straight lines.
488
+ """
489
+ route = data_sets.get(problem_id)
490
+ if not route:
491
+ raise HTTPException(status_code=404, detail="Route plan not found")
492
+
493
+ distance_matrix = Location.get_distance_matrix()
494
+ if distance_matrix is None:
495
+ # No distance matrix available - return empty geometries
496
+ return RouteGeometryResponse(geometries={})
497
+
498
+ geometries: Dict[str, List[Optional[str]]] = {}
499
+
500
+ for vehicle in route.vehicles:
501
+ segments: List[Optional[str]] = []
502
+
503
+ if not vehicle.visits:
504
+ # No visits assigned to this vehicle
505
+ geometries[vehicle.id] = segments
506
+ continue
507
+
508
+ # Segment from depot to first visit
509
+ prev_location = vehicle.home_location
510
+ for visit in vehicle.visits:
511
+ geometry = distance_matrix.get_geometry(prev_location, visit.location)
512
+ segments.append(geometry)
513
+ prev_location = visit.location
514
+
515
+ # Segment from last visit back to depot
516
+ geometry = distance_matrix.get_geometry(prev_location, vehicle.home_location)
517
+ segments.append(geometry)
518
+
519
+ geometries[vehicle.id] = segments
520
+
521
+ return RouteGeometryResponse(geometries=geometries)
522
+
523
+
524
  app.mount("/", StaticFiles(directory="static", html=True), name="static")
src/vehicle_routing/routing.py ADDED
@@ -0,0 +1,622 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Real-world routing service using OSMnx for road network data.
3
+
4
+ This module provides:
5
+ - OSMnxRoutingService: Downloads OSM network, caches locally, computes routes
6
+ - DistanceMatrix: Precomputes all pairwise routes with times and geometries
7
+ - Haversine fallback when OSMnx is unavailable
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import math
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Optional
17
+
18
+ import polyline
19
+
20
+ if TYPE_CHECKING:
21
+ from .domain import Location
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Cache directory for OSM network data
26
+ CACHE_DIR = Path(__file__).parent.parent.parent / ".osm_cache"
27
+
28
+
29
+ @dataclass
30
+ class RouteResult:
31
+ """Result from a routing query."""
32
+
33
+ duration_seconds: int
34
+ distance_meters: int
35
+ geometry: Optional[str] = None # Encoded polyline
36
+
37
+
38
+ @dataclass
39
+ class DistanceMatrix:
40
+ """
41
+ Precomputed distance/time matrix for all location pairs.
42
+
43
+ Stores RouteResult for each (origin, destination) pair,
44
+ enabling O(1) lookup during solver execution.
45
+ """
46
+
47
+ _matrix: dict[tuple[tuple[float, float], tuple[float, float]], RouteResult] = field(
48
+ default_factory=dict
49
+ )
50
+
51
+ def _key(
52
+ self, origin: "Location", destination: "Location"
53
+ ) -> tuple[tuple[float, float], tuple[float, float]]:
54
+ """Create hashable key from two locations."""
55
+ return (
56
+ (origin.latitude, origin.longitude),
57
+ (destination.latitude, destination.longitude),
58
+ )
59
+
60
+ def set_route(
61
+ self, origin: "Location", destination: "Location", result: RouteResult
62
+ ) -> None:
63
+ """Store a route result in the matrix."""
64
+ self._matrix[self._key(origin, destination)] = result
65
+
66
+ def get_route(
67
+ self, origin: "Location", destination: "Location"
68
+ ) -> Optional[RouteResult]:
69
+ """Get a route result from the matrix."""
70
+ return self._matrix.get(self._key(origin, destination))
71
+
72
+ def get_driving_time(self, origin: "Location", destination: "Location") -> int:
73
+ """Get driving time in seconds between two locations."""
74
+ result = self.get_route(origin, destination)
75
+ if result is None:
76
+ # Fallback to haversine if not in matrix
77
+ return _haversine_driving_time(origin, destination)
78
+ return result.duration_seconds
79
+
80
+ def get_geometry(
81
+ self, origin: "Location", destination: "Location"
82
+ ) -> Optional[str]:
83
+ """Get encoded polyline geometry for a route segment."""
84
+ result = self.get_route(origin, destination)
85
+ return result.geometry if result else None
86
+
87
+
88
+ def _haversine_driving_time(origin: "Location", destination: "Location") -> int:
89
+ """
90
+ Calculate driving time using haversine formula (fallback).
91
+
92
+ Uses 50 km/h average speed assumption.
93
+ """
94
+ if (
95
+ origin.latitude == destination.latitude
96
+ and origin.longitude == destination.longitude
97
+ ):
98
+ return 0
99
+
100
+ EARTH_RADIUS_M = 6371000
101
+ AVERAGE_SPEED_KMPH = 50
102
+
103
+ lat1 = math.radians(origin.latitude)
104
+ lon1 = math.radians(origin.longitude)
105
+ lat2 = math.radians(destination.latitude)
106
+ lon2 = math.radians(destination.longitude)
107
+
108
+ # Haversine formula
109
+ dlat = lat2 - lat1
110
+ dlon = lon2 - lon1
111
+ a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
112
+ c = 2 * math.asin(math.sqrt(a))
113
+ distance_meters = EARTH_RADIUS_M * c
114
+
115
+ # Convert to driving time
116
+ return round(distance_meters / AVERAGE_SPEED_KMPH * 3.6)
117
+
118
+
119
+ class OSMnxRoutingService:
120
+ """
121
+ Routing service using OSMnx for real road network data.
122
+
123
+ Downloads the OSM network for a given bounding box, caches it locally,
124
+ and computes shortest paths using NetworkX.
125
+ """
126
+
127
+ def __init__(self, cache_dir: Path = CACHE_DIR):
128
+ self.cache_dir = cache_dir
129
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
130
+ self._graph = None
131
+ self._graph_bbox = None
132
+
133
+ def _get_cache_path(
134
+ self, north: float, south: float, east: float, west: float
135
+ ) -> Path:
136
+ """Generate cache file path for a bounding box."""
137
+ # Round to 2 decimal places for cache key
138
+ key = f"osm_{north:.2f}_{south:.2f}_{east:.2f}_{west:.2f}.graphml"
139
+ return self.cache_dir / key
140
+
141
+ def load_network(
142
+ self, north: float, south: float, east: float, west: float, padding: float = 0.01
143
+ ) -> bool:
144
+ """
145
+ Load OSM road network for the given bounding box.
146
+
147
+ Args:
148
+ north, south, east, west: Bounding box coordinates
149
+ padding: Extra padding around the bbox (in degrees)
150
+
151
+ Returns:
152
+ True if network loaded successfully, False otherwise
153
+ """
154
+ try:
155
+ import osmnx as ox
156
+
157
+ # Add padding to ensure we have roads outside the strict bbox
158
+ north += padding
159
+ south -= padding
160
+ east += padding
161
+ west -= padding
162
+
163
+ cache_path = self._get_cache_path(north, south, east, west)
164
+
165
+ if cache_path.exists() and cache_path.stat().st_size > 0:
166
+ logger.info(f"Loading cached OSM network from {cache_path}")
167
+ self._graph = ox.load_graphml(cache_path)
168
+
169
+ # Check if the cached graph already has travel_time
170
+ # (we now save enriched graphs)
171
+ sample_edge = next(iter(self._graph.edges(data=True)), None)
172
+ has_travel_time = sample_edge and "travel_time" in sample_edge[2]
173
+
174
+ if not has_travel_time:
175
+ logger.info("Adding edge speeds and travel times to cached graph...")
176
+ self._graph = ox.add_edge_speeds(self._graph)
177
+ self._graph = ox.add_edge_travel_times(self._graph)
178
+ # Re-save with travel times included
179
+ ox.save_graphml(self._graph, cache_path)
180
+ logger.info("Updated cache with travel times")
181
+ else:
182
+ logger.info(
183
+ f"Downloading OSM network for bbox: N={north:.4f}, S={south:.4f}, E={east:.4f}, W={west:.4f}"
184
+ )
185
+ # OSMnx 2.x uses bbox as tuple: (left, bottom, right, top) = (west, south, east, north)
186
+ bbox_tuple = (west, south, east, north)
187
+ self._graph = ox.graph_from_bbox(
188
+ bbox=bbox_tuple,
189
+ network_type="drive",
190
+ simplify=True,
191
+ )
192
+
193
+ # Add edge speeds and travel times BEFORE caching
194
+ logger.info("Computing edge speeds and travel times...")
195
+ self._graph = ox.add_edge_speeds(self._graph)
196
+ self._graph = ox.add_edge_travel_times(self._graph)
197
+
198
+ # Save enriched graph to cache
199
+ ox.save_graphml(self._graph, cache_path)
200
+ logger.info(f"Saved enriched OSM network to cache: {cache_path}")
201
+
202
+ self._graph_bbox = (north, south, east, west)
203
+ logger.info(
204
+ f"OSM network loaded: {self._graph.number_of_nodes()} nodes, "
205
+ f"{self._graph.number_of_edges()} edges"
206
+ )
207
+ return True
208
+
209
+ except ImportError:
210
+ logger.warning("OSMnx not installed, falling back to haversine")
211
+ return False
212
+ except Exception as e:
213
+ logger.warning(f"Failed to load OSM network: {e}, falling back to haversine")
214
+ return False
215
+
216
+ def get_nearest_node(self, location: "Location") -> Optional[int]:
217
+ """Get the nearest graph node for a location."""
218
+ if self._graph is None:
219
+ return None
220
+ try:
221
+ import osmnx as ox
222
+ return ox.nearest_nodes(self._graph, location.longitude, location.latitude)
223
+ except Exception:
224
+ return None
225
+
226
+ def compute_all_routes(
227
+ self,
228
+ locations: list["Location"],
229
+ progress_callback=None
230
+ ) -> dict[tuple[int, int], RouteResult]:
231
+ """
232
+ Compute all pairwise routes efficiently using batch shortest paths.
233
+
234
+ Returns a dict mapping (origin_idx, dest_idx) to RouteResult.
235
+ """
236
+ import networkx as nx
237
+
238
+ if self._graph is None:
239
+ return {}
240
+
241
+ results = {}
242
+ n = len(locations)
243
+
244
+ # Map locations to nearest nodes (batch operation)
245
+ if progress_callback:
246
+ progress_callback("routes", "Finding nearest road nodes...", 30, f"{n} locations")
247
+
248
+ nodes = []
249
+ for loc in locations:
250
+ node = self.get_nearest_node(loc)
251
+ nodes.append(node)
252
+
253
+ # Compute shortest paths from each origin to ALL destinations at once
254
+ # This is MUCH faster than individual shortest_path calls
255
+ total_origins = sum(1 for node in nodes if node is not None)
256
+ processed = 0
257
+
258
+ for i, origin_node in enumerate(nodes):
259
+ if origin_node is None:
260
+ continue
261
+
262
+ # Compute shortest paths from this origin to all nodes at once
263
+ # Using Dijkstra's algorithm with single-source
264
+ try:
265
+ lengths, paths = nx.single_source_dijkstra(
266
+ self._graph, origin_node, weight="travel_time"
267
+ )
268
+ except nx.NetworkXError:
269
+ continue
270
+
271
+ for j, dest_node in enumerate(nodes):
272
+ if dest_node is None:
273
+ continue
274
+
275
+ origin_loc = locations[i]
276
+ dest_loc = locations[j]
277
+
278
+ if i == j or origin_node == dest_node:
279
+ # Same location
280
+ results[(i, j)] = RouteResult(
281
+ duration_seconds=0,
282
+ distance_meters=0,
283
+ geometry=polyline.encode(
284
+ [(origin_loc.latitude, origin_loc.longitude)], precision=5
285
+ ),
286
+ )
287
+ elif dest_node in paths:
288
+ path = paths[dest_node]
289
+ travel_time = lengths[dest_node]
290
+
291
+ # Calculate distance and extract geometry
292
+ total_distance = 0
293
+ coordinates = []
294
+
295
+ for k in range(len(path) - 1):
296
+ u, v = path[k], path[k + 1]
297
+ edge_data = self._graph.get_edge_data(u, v)
298
+ if edge_data:
299
+ edge = edge_data[0] if isinstance(edge_data, dict) else edge_data
300
+ total_distance += edge.get("length", 0)
301
+
302
+ for node in path:
303
+ node_data = self._graph.nodes[node]
304
+ coordinates.append((node_data["y"], node_data["x"]))
305
+
306
+ results[(i, j)] = RouteResult(
307
+ duration_seconds=round(travel_time),
308
+ distance_meters=round(total_distance),
309
+ geometry=polyline.encode(coordinates, precision=5),
310
+ )
311
+
312
+ processed += 1
313
+ if progress_callback and processed % max(1, total_origins // 10) == 0:
314
+ percent = 30 + int((processed / total_origins) * 65)
315
+ progress_callback(
316
+ "routes",
317
+ "Computing routes...",
318
+ percent,
319
+ f"{processed}/{total_origins} origins processed"
320
+ )
321
+
322
+ return results
323
+
324
+ def get_route(
325
+ self, origin: "Location", destination: "Location"
326
+ ) -> Optional[RouteResult]:
327
+ """
328
+ Compute route between two locations.
329
+
330
+ Returns:
331
+ RouteResult with duration, distance, and geometry, or None if routing fails
332
+ """
333
+ if self._graph is None:
334
+ return None
335
+
336
+ try:
337
+ import osmnx as ox
338
+
339
+ # Find nearest nodes to origin and destination
340
+ origin_node = ox.nearest_nodes(
341
+ self._graph, origin.longitude, origin.latitude
342
+ )
343
+ dest_node = ox.nearest_nodes(
344
+ self._graph, destination.longitude, destination.latitude
345
+ )
346
+
347
+ # Same node means same location (or very close)
348
+ if origin_node == dest_node:
349
+ return RouteResult(
350
+ duration_seconds=0,
351
+ distance_meters=0,
352
+ geometry=polyline.encode(
353
+ [(origin.latitude, origin.longitude)], precision=5
354
+ ),
355
+ )
356
+
357
+ # Compute shortest path by travel time
358
+ route = ox.shortest_path(
359
+ self._graph, origin_node, dest_node, weight="travel_time"
360
+ )
361
+
362
+ if route is None:
363
+ logger.warning(
364
+ f"No route found between {origin} and {destination}"
365
+ )
366
+ return None
367
+
368
+ # Extract route attributes
369
+ total_time = 0
370
+ total_distance = 0
371
+ coordinates = []
372
+
373
+ for i in range(len(route) - 1):
374
+ u, v = route[i], route[i + 1]
375
+ edge_data = self._graph.get_edge_data(u, v)
376
+ if edge_data:
377
+ # Get the first edge if multiple exist
378
+ edge = edge_data[0] if isinstance(edge_data, dict) else edge_data
379
+ total_time += edge.get("travel_time", 0)
380
+ total_distance += edge.get("length", 0)
381
+
382
+ # Get node coordinates for geometry
383
+ for node in route:
384
+ node_data = self._graph.nodes[node]
385
+ coordinates.append((node_data["y"], node_data["x"]))
386
+
387
+ # Encode geometry as polyline
388
+ encoded_geometry = polyline.encode(coordinates, precision=5)
389
+
390
+ return RouteResult(
391
+ duration_seconds=round(total_time),
392
+ distance_meters=round(total_distance),
393
+ geometry=encoded_geometry,
394
+ )
395
+
396
+ except Exception as e:
397
+ logger.warning(f"Routing failed: {e}")
398
+ return None
399
+
400
+
401
+ def compute_distance_matrix(
402
+ locations: list["Location"],
403
+ routing_service: Optional[OSMnxRoutingService] = None,
404
+ bbox: Optional[tuple[float, float, float, float]] = None,
405
+ ) -> DistanceMatrix:
406
+ """
407
+ Compute distance matrix for all location pairs.
408
+
409
+ Args:
410
+ locations: List of Location objects
411
+ routing_service: Optional pre-configured routing service
412
+ bbox: Optional (north, south, east, west) tuple for network download
413
+
414
+ Returns:
415
+ DistanceMatrix with precomputed routes
416
+ """
417
+ return compute_distance_matrix_with_progress(
418
+ locations, routing_service, bbox, use_osm=True, progress_callback=None
419
+ )
420
+
421
+
422
+ def compute_distance_matrix_with_progress(
423
+ locations: list["Location"],
424
+ bbox: Optional[tuple[float, float, float, float]] = None,
425
+ use_osm: bool = True,
426
+ progress_callback=None,
427
+ routing_service: Optional[OSMnxRoutingService] = None,
428
+ ) -> DistanceMatrix:
429
+ """
430
+ Compute distance matrix for all location pairs with progress reporting.
431
+
432
+ Args:
433
+ locations: List of Location objects
434
+ bbox: Optional (north, south, east, west) tuple for network download
435
+ use_osm: If True, try to use OSMnx for real routing. If False, use haversine.
436
+ progress_callback: Optional callback(phase, message, percent, detail) for progress updates
437
+ routing_service: Optional pre-configured routing service
438
+
439
+ Returns:
440
+ DistanceMatrix with precomputed routes
441
+ """
442
+ matrix = DistanceMatrix()
443
+
444
+ if not locations:
445
+ return matrix
446
+
447
+ def report_progress(phase: str, message: str, percent: int, detail: str = ""):
448
+ if progress_callback:
449
+ progress_callback(phase, message, percent, detail)
450
+ logger.info(f"[{phase}] {message} ({percent}%) {detail}")
451
+
452
+ # Compute bounding box from locations if not provided
453
+ if bbox is None:
454
+ lats = [loc.latitude for loc in locations]
455
+ lons = [loc.longitude for loc in locations]
456
+ bbox = (max(lats), min(lats), max(lons), min(lons))
457
+
458
+ osm_loaded = False
459
+
460
+ if use_osm:
461
+ # Create routing service if not provided
462
+ if routing_service is None:
463
+ routing_service = OSMnxRoutingService()
464
+
465
+ report_progress("network", "Checking for cached road network...", 5)
466
+
467
+ # Check if cached
468
+ north, south, east, west = bbox
469
+ north += 0.01 # padding
470
+ south -= 0.01
471
+ east += 0.01
472
+ west -= 0.01
473
+
474
+ cache_path = routing_service._get_cache_path(north, south, east, west)
475
+ is_cached = cache_path.exists()
476
+
477
+ if is_cached:
478
+ report_progress("network", "Loading cached road network...", 10, str(cache_path.name))
479
+ else:
480
+ report_progress(
481
+ "network",
482
+ "Downloading OpenStreetMap road network...",
483
+ 10,
484
+ f"Area: {abs(north-south):.2f}° × {abs(east-west):.2f}°"
485
+ )
486
+
487
+ # Try to load OSM network
488
+ osm_loaded = routing_service.load_network(
489
+ north=bbox[0], south=bbox[1], east=bbox[2], west=bbox[3]
490
+ )
491
+
492
+ if osm_loaded:
493
+ node_count = routing_service._graph.number_of_nodes()
494
+ edge_count = routing_service._graph.number_of_edges()
495
+ report_progress(
496
+ "network",
497
+ "Road network loaded",
498
+ 25,
499
+ f"{node_count:,} nodes, {edge_count:,} edges"
500
+ )
501
+ else:
502
+ report_progress("network", "OSMnx unavailable, using haversine", 25)
503
+ else:
504
+ report_progress("network", "Using fast haversine mode", 25)
505
+
506
+ # Compute all pairwise routes
507
+ total_pairs = len(locations) * len(locations)
508
+
509
+ if osm_loaded and routing_service:
510
+ # Use batch routing for OSMnx (MUCH faster than individual calls)
511
+ report_progress(
512
+ "routes",
513
+ f"Computing {total_pairs:,} routes (batch mode)...",
514
+ 30,
515
+ f"{len(locations)} locations"
516
+ )
517
+
518
+ batch_results = routing_service.compute_all_routes(
519
+ locations,
520
+ progress_callback=report_progress
521
+ )
522
+
523
+ # Transfer batch results to matrix, with haversine fallback for missing routes
524
+ computed = 0
525
+ for i, origin in enumerate(locations):
526
+ for j, destination in enumerate(locations):
527
+ if (i, j) in batch_results:
528
+ matrix.set_route(origin, destination, batch_results[(i, j)])
529
+ else:
530
+ # Fallback to haversine for routes not found
531
+ matrix.set_route(
532
+ origin,
533
+ destination,
534
+ RouteResult(
535
+ duration_seconds=_haversine_driving_time(origin, destination),
536
+ distance_meters=_haversine_distance_meters(origin, destination),
537
+ geometry=_straight_line_geometry(origin, destination),
538
+ ),
539
+ )
540
+ computed += 1
541
+
542
+ report_progress("complete", "Distance matrix ready", 100, f"{computed:,} routes computed")
543
+ else:
544
+ # Use haversine fallback for all routes
545
+ report_progress(
546
+ "routes",
547
+ f"Computing {total_pairs:,} route pairs...",
548
+ 30,
549
+ f"{len(locations)} locations"
550
+ )
551
+
552
+ computed = 0
553
+ for origin in locations:
554
+ for destination in locations:
555
+ if origin is destination:
556
+ matrix.set_route(
557
+ origin,
558
+ destination,
559
+ RouteResult(
560
+ duration_seconds=0,
561
+ distance_meters=0,
562
+ geometry=polyline.encode(
563
+ [(origin.latitude, origin.longitude)], precision=5
564
+ ),
565
+ ),
566
+ )
567
+ else:
568
+ matrix.set_route(
569
+ origin,
570
+ destination,
571
+ RouteResult(
572
+ duration_seconds=_haversine_driving_time(origin, destination),
573
+ distance_meters=_haversine_distance_meters(origin, destination),
574
+ geometry=_straight_line_geometry(origin, destination),
575
+ ),
576
+ )
577
+ computed += 1
578
+
579
+ # Report progress every 5%
580
+ if total_pairs > 0 and computed % max(1, total_pairs // 20) == 0:
581
+ percent_complete = int(30 + (computed / total_pairs) * 65)
582
+ report_progress(
583
+ "routes",
584
+ f"Computing routes...",
585
+ percent_complete,
586
+ f"{computed:,}/{total_pairs:,} pairs"
587
+ )
588
+
589
+ report_progress("complete", "Distance matrix ready", 100, f"{computed:,} routes computed")
590
+
591
+ return matrix
592
+
593
+
594
+ def _haversine_distance_meters(origin: "Location", destination: "Location") -> int:
595
+ """Calculate haversine distance in meters."""
596
+ if (
597
+ origin.latitude == destination.latitude
598
+ and origin.longitude == destination.longitude
599
+ ):
600
+ return 0
601
+
602
+ EARTH_RADIUS_M = 6371000
603
+
604
+ lat1 = math.radians(origin.latitude)
605
+ lon1 = math.radians(origin.longitude)
606
+ lat2 = math.radians(destination.latitude)
607
+ lon2 = math.radians(destination.longitude)
608
+
609
+ dlat = lat2 - lat1
610
+ dlon = lon2 - lon1
611
+ a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
612
+ c = 2 * math.asin(math.sqrt(a))
613
+
614
+ return round(EARTH_RADIUS_M * c)
615
+
616
+
617
+ def _straight_line_geometry(origin: "Location", destination: "Location") -> str:
618
+ """Generate a straight-line encoded polyline between two points."""
619
+ return polyline.encode(
620
+ [(origin.latitude, origin.longitude), (destination.latitude, destination.longitude)],
621
+ precision=5,
622
+ )
static/app.js CHANGED
@@ -6,11 +6,155 @@ let scheduleId = null;
6
  let loadedRoutePlan = null;
7
  let newVisit = null;
8
  let visitMarker = null;
 
 
9
  const solveButton = $("#solveButton");
10
  const stopSolvingButton = $("#stopSolvingButton");
11
  const vehiclesTable = $("#vehicles");
12
  const analyzeButton = $("#analyzeButton");
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  /*************************************** Map constants and variable definitions **************************************/
15
 
16
  const homeLocationMarkerByIdMap = new Map();
@@ -155,6 +299,23 @@ $(document).ready(function () {
155
  }
156
  });
157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  setupAjax();
159
  fetchDemoData();
160
  });
@@ -265,7 +426,7 @@ function getNextVehicleName() {
265
  return `Vehicle ${loadedRoutePlan.vehicles.length + 1}`;
266
  }
267
 
268
- function confirmAddVehicle() {
269
  const vehicleName = $("#vehicleName").val().trim() || getNextVehicleName();
270
  const capacity = parseInt($("#vehicleCapacity").val());
271
  const lat = parseFloat($("#vehicleHomeLat").val());
@@ -321,13 +482,13 @@ function confirmAddVehicle() {
321
  }
322
 
323
  // Refresh display
324
- renderRoutes(loadedRoutePlan);
325
  renderTimelines(loadedRoutePlan);
326
 
327
  showNotification(`Vehicle "${vehicleName}" added successfully!`, "success");
328
  }
329
 
330
- function removeLastVehicle() {
331
  if (optimizing) {
332
  alert("Cannot remove vehicles while solving. Please stop solving first.");
333
  return;
@@ -367,13 +528,13 @@ function removeLastVehicle() {
367
  }
368
 
369
  // Refresh display
370
- renderRoutes(loadedRoutePlan);
371
  renderTimelines(loadedRoutePlan);
372
 
373
  showNotification(`Vehicle "${lastVehicle.name || lastVehicle.id}" removed.`, "info");
374
  }
375
 
376
- function removeVehicle(vehicleId) {
377
  if (optimizing) {
378
  alert("Cannot remove vehicles while solving. Please stop solving first.");
379
  return;
@@ -417,7 +578,7 @@ function removeVehicle(vehicleId) {
417
  }
418
 
419
  // Refresh display
420
- renderRoutes(loadedRoutePlan);
421
  renderTimelines(loadedRoutePlan);
422
 
423
  showNotification(`Vehicle "${vehicle.name || vehicleId}" removed.`, "info");
@@ -596,11 +757,16 @@ function createRouteNumberIcon(number, color) {
596
  });
597
  }
598
 
599
- function renderRouteLines(highlightedId = null) {
600
  routeGroup.clearLayers();
601
 
602
  if (!loadedRoutePlan) return;
603
 
 
 
 
 
 
604
  const visitByIdMap = new Map(loadedRoutePlan.visits.map(visit => [visit.id, visit]));
605
 
606
  for (let vehicle of loadedRoutePlan.vehicles) {
@@ -612,7 +778,24 @@ function renderRouteLines(highlightedId = null) {
612
  const weight = isHighlighted && highlightedId !== null ? 5 : 3;
613
  const opacity = isHighlighted ? 1 : 0.2;
614
 
615
- if (locations.length > 0) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
616
  L.polyline([homeLocation, ...locations, homeLocation], {
617
  color: color,
618
  weight: weight,
@@ -773,7 +956,7 @@ function getVisitMarker(visit) {
773
  return marker;
774
  }
775
 
776
- function renderRoutes(solution) {
777
  if (!initialized) {
778
  const bounds = [solution.southWestCorner, solution.northEastCorner];
779
  map.fitBounds(bounds);
@@ -827,8 +1010,8 @@ function renderRoutes(solution) {
827
  solution.visits.forEach(function (visit) {
828
  getVisitMarker(visit).setPopupContent(visitPopupContent(visit));
829
  });
830
- // Route - use the dedicated function which handles highlighting
831
- renderRouteLines(highlightedVehicleId);
832
 
833
  // Summary
834
  $("#score").text(solution.score ? `Score: ${solution.score}` : "Score: ?");
@@ -841,7 +1024,7 @@ function renderTimelines(routePlan) {
841
  byVehicleItemData.clear();
842
  byVisitItemData.clear();
843
 
844
- // Build lookup maps for enhanced display
845
  const vehicleById = new Map(routePlan.vehicles.map(v => [v.id, v]));
846
  const visitById = new Map(routePlan.visits.map(v => [v.id, v]));
847
  const visitOrderMap = new Map();
@@ -1162,9 +1345,9 @@ function applyRecommendationModal(recommendations) {
1162
  );
1163
  }
1164
 
1165
- function updateSolutionWithNewVisit(newSolution) {
1166
  loadedRoutePlan = newSolution;
1167
- renderRoutes(newSolution);
1168
  renderTimelines(newSolution);
1169
  $('#newVisitModal').modal('hide');
1170
  }
@@ -1199,6 +1382,9 @@ function setupAjax() {
1199
  }
1200
 
1201
  function solve() {
 
 
 
1202
  $.ajax({
1203
  url: "/route-plans",
1204
  type: "POST",
@@ -1240,30 +1426,52 @@ function refreshSolvingButtons(solving) {
1240
  }
1241
  }
1242
 
1243
- function refreshRoutePlan() {
1244
  let path = "/route-plans/" + scheduleId;
1245
- if (scheduleId === null) {
 
 
1246
  if (demoDataId === null) {
1247
  alert("Please select a test data set.");
1248
  return;
1249
  }
1250
 
1251
- path = "/demo-data/" + demoDataId;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1252
  }
1253
 
1254
- $.getJSON(path, function (routePlan) {
 
 
1255
  loadedRoutePlan = routePlan;
1256
  refreshSolvingButtons(
1257
  routePlan.solverStatus != null &&
1258
  routePlan.solverStatus !== "NOT_SOLVING",
1259
  );
1260
- renderRoutes(routePlan);
1261
  renderTimelines(routePlan);
1262
  initialized = true;
1263
- }).fail(function (xhr, ajaxOptions, thrownError) {
1264
- showError("Getting route plan has failed.", xhr);
1265
  refreshSolvingButtons(false);
1266
- });
1267
  }
1268
 
1269
  function stopSolving() {
@@ -1360,6 +1568,12 @@ function replaceQuickstartSolverForgeAutoHeaderFooter() {
1360
  </ul>
1361
  </div>
1362
  <div class="ms-auto d-flex align-items-center gap-3">
 
 
 
 
 
 
1363
  <div class="dropdown">
1364
  <button class="btn dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" style="background-color: #10b981; color: #ffffff; border-color: #10b981;">
1365
  Data
 
6
  let loadedRoutePlan = null;
7
  let newVisit = null;
8
  let visitMarker = null;
9
+ let routeGeometries = null; // Cache for encoded polyline geometries
10
+ let useRealRoads = false; // Routing mode toggle state
11
  const solveButton = $("#solveButton");
12
  const stopSolvingButton = $("#stopSolvingButton");
13
  const vehiclesTable = $("#vehicles");
14
  const analyzeButton = $("#analyzeButton");
15
 
16
+ /**
17
+ * Decode an encoded polyline string into an array of [lat, lng] coordinates.
18
+ * This is the Google polyline encoding algorithm.
19
+ * @param {string} encoded - The encoded polyline string
20
+ * @returns {Array<Array<number>>} Array of [lat, lng] coordinate pairs
21
+ */
22
+ function decodePolyline(encoded) {
23
+ if (!encoded) return [];
24
+
25
+ const points = [];
26
+ let index = 0;
27
+ let lat = 0;
28
+ let lng = 0;
29
+
30
+ while (index < encoded.length) {
31
+ // Decode latitude
32
+ let shift = 0;
33
+ let result = 0;
34
+ let byte;
35
+ do {
36
+ byte = encoded.charCodeAt(index++) - 63;
37
+ result |= (byte & 0x1f) << shift;
38
+ shift += 5;
39
+ } while (byte >= 0x20);
40
+ const dlat = (result & 1) ? ~(result >> 1) : (result >> 1);
41
+ lat += dlat;
42
+
43
+ // Decode longitude
44
+ shift = 0;
45
+ result = 0;
46
+ do {
47
+ byte = encoded.charCodeAt(index++) - 63;
48
+ result |= (byte & 0x1f) << shift;
49
+ shift += 5;
50
+ } while (byte >= 0x20);
51
+ const dlng = (result & 1) ? ~(result >> 1) : (result >> 1);
52
+ lng += dlng;
53
+
54
+ // Polyline encoding uses precision of 5 decimal places
55
+ points.push([lat / 1e5, lng / 1e5]);
56
+ }
57
+
58
+ return points;
59
+ }
60
+
61
+ /**
62
+ * Fetch route geometries for the current schedule from the backend.
63
+ * @returns {Promise<Object|null>} The geometries object or null if unavailable
64
+ */
65
+ async function fetchRouteGeometries() {
66
+ if (!scheduleId) return null;
67
+
68
+ try {
69
+ const response = await fetch(`/route-plans/${scheduleId}/geometry`);
70
+ if (response.ok) {
71
+ const data = await response.json();
72
+ return data.geometries || null;
73
+ }
74
+ } catch (e) {
75
+ console.warn('Could not fetch route geometries:', e);
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /*************************************** Loading Overlay Functions **************************************/
81
+
82
+ function showLoadingOverlay(title = "Loading Demo Data", message = "Initializing...") {
83
+ $("#loadingTitle").text(title);
84
+ $("#loadingMessage").text(message);
85
+ $("#loadingProgress").css("width", "0%");
86
+ $("#loadingDetail").text("");
87
+ $("#loadingOverlay").removeClass("hidden");
88
+ }
89
+
90
+ function hideLoadingOverlay() {
91
+ $("#loadingOverlay").addClass("hidden");
92
+ }
93
+
94
+ function updateLoadingProgress(message, percent, detail = "") {
95
+ $("#loadingMessage").text(message);
96
+ $("#loadingProgress").css("width", `${percent}%`);
97
+ $("#loadingDetail").text(detail);
98
+ }
99
+
100
+ /**
101
+ * Load demo data with progress updates via Server-Sent Events.
102
+ * Used when Real Roads mode is enabled.
103
+ */
104
+ function loadDemoDataWithProgress(demoId) {
105
+ return new Promise((resolve, reject) => {
106
+ const routingMode = useRealRoads ? "real_roads" : "haversine";
107
+ const url = `/demo-data/${demoId}/stream?routing=${routingMode}`;
108
+
109
+ showLoadingOverlay(
110
+ useRealRoads ? "Loading Real Road Data" : "Loading Demo Data",
111
+ "Connecting..."
112
+ );
113
+
114
+ const eventSource = new EventSource(url);
115
+ let solution = null;
116
+
117
+ eventSource.onmessage = function(event) {
118
+ try {
119
+ const data = JSON.parse(event.data);
120
+
121
+ if (data.event === "progress") {
122
+ let statusIcon = "";
123
+ if (data.phase === "network") {
124
+ statusIcon = '<i class="fas fa-download me-2"></i>';
125
+ } else if (data.phase === "routes") {
126
+ statusIcon = '<i class="fas fa-route me-2"></i>';
127
+ } else if (data.phase === "complete") {
128
+ statusIcon = '<i class="fas fa-check-circle me-2 text-success"></i>';
129
+ }
130
+ updateLoadingProgress(data.message, data.percent, data.detail || "");
131
+ } else if (data.event === "complete") {
132
+ solution = data.solution;
133
+ // Store geometries from the response if available
134
+ if (data.geometries) {
135
+ routeGeometries = data.geometries;
136
+ }
137
+ eventSource.close();
138
+ hideLoadingOverlay();
139
+ resolve(solution);
140
+ } else if (data.event === "error") {
141
+ eventSource.close();
142
+ hideLoadingOverlay();
143
+ reject(new Error(data.message));
144
+ }
145
+ } catch (e) {
146
+ console.error("Error parsing SSE event:", e);
147
+ }
148
+ };
149
+
150
+ eventSource.onerror = function(error) {
151
+ eventSource.close();
152
+ hideLoadingOverlay();
153
+ reject(new Error("Connection lost while loading data"));
154
+ };
155
+ });
156
+ }
157
+
158
  /*************************************** Map constants and variable definitions **************************************/
159
 
160
  const homeLocationMarkerByIdMap = new Map();
 
299
  }
300
  });
301
 
302
+ // Real Roads toggle handler
303
+ $(document).on('change', '#realRoadRouting', function() {
304
+ useRealRoads = $(this).is(':checked');
305
+
306
+ // If we have a demo dataset loaded, reload it with the new routing mode
307
+ if (demoDataId && !optimizing) {
308
+ scheduleId = null;
309
+ initialized = false;
310
+ homeLocationGroup.clearLayers();
311
+ homeLocationMarkerByIdMap.clear();
312
+ visitGroup.clearLayers();
313
+ visitMarkerByIdMap.clear();
314
+ routeGeometries = null;
315
+ refreshRoutePlan();
316
+ }
317
+ });
318
+
319
  setupAjax();
320
  fetchDemoData();
321
  });
 
426
  return `Vehicle ${loadedRoutePlan.vehicles.length + 1}`;
427
  }
428
 
429
+ async function confirmAddVehicle() {
430
  const vehicleName = $("#vehicleName").val().trim() || getNextVehicleName();
431
  const capacity = parseInt($("#vehicleCapacity").val());
432
  const lat = parseFloat($("#vehicleHomeLat").val());
 
482
  }
483
 
484
  // Refresh display
485
+ await renderRoutes(loadedRoutePlan);
486
  renderTimelines(loadedRoutePlan);
487
 
488
  showNotification(`Vehicle "${vehicleName}" added successfully!`, "success");
489
  }
490
 
491
+ async function removeLastVehicle() {
492
  if (optimizing) {
493
  alert("Cannot remove vehicles while solving. Please stop solving first.");
494
  return;
 
528
  }
529
 
530
  // Refresh display
531
+ await renderRoutes(loadedRoutePlan);
532
  renderTimelines(loadedRoutePlan);
533
 
534
  showNotification(`Vehicle "${lastVehicle.name || lastVehicle.id}" removed.`, "info");
535
  }
536
 
537
+ async function removeVehicle(vehicleId) {
538
  if (optimizing) {
539
  alert("Cannot remove vehicles while solving. Please stop solving first.");
540
  return;
 
578
  }
579
 
580
  // Refresh display
581
+ await renderRoutes(loadedRoutePlan);
582
  renderTimelines(loadedRoutePlan);
583
 
584
  showNotification(`Vehicle "${vehicle.name || vehicleId}" removed.`, "info");
 
757
  });
758
  }
759
 
760
+ async function renderRouteLines(highlightedId = null) {
761
  routeGroup.clearLayers();
762
 
763
  if (!loadedRoutePlan) return;
764
 
765
+ // Fetch geometries during solving (routes change)
766
+ if (scheduleId) {
767
+ routeGeometries = await fetchRouteGeometries();
768
+ }
769
+
770
  const visitByIdMap = new Map(loadedRoutePlan.visits.map(visit => [visit.id, visit]));
771
 
772
  for (let vehicle of loadedRoutePlan.vehicles) {
 
778
  const weight = isHighlighted && highlightedId !== null ? 5 : 3;
779
  const opacity = isHighlighted ? 1 : 0.2;
780
 
781
+ const vehicleGeometry = routeGeometries?.[vehicle.id];
782
+
783
+ if (vehicleGeometry && vehicleGeometry.length > 0) {
784
+ // Draw real road routes using decoded polylines
785
+ for (const encodedSegment of vehicleGeometry) {
786
+ if (encodedSegment) {
787
+ const points = decodePolyline(encodedSegment);
788
+ if (points.length > 0) {
789
+ L.polyline(points, {
790
+ color: color,
791
+ weight: weight,
792
+ opacity: opacity
793
+ }).addTo(routeGroup);
794
+ }
795
+ }
796
+ }
797
+ } else if (locations.length > 0) {
798
+ // Fallback to straight lines if no geometry available
799
  L.polyline([homeLocation, ...locations, homeLocation], {
800
  color: color,
801
  weight: weight,
 
956
  return marker;
957
  }
958
 
959
+ async function renderRoutes(solution) {
960
  if (!initialized) {
961
  const bounds = [solution.southWestCorner, solution.northEastCorner];
962
  map.fitBounds(bounds);
 
1010
  solution.visits.forEach(function (visit) {
1011
  getVisitMarker(visit).setPopupContent(visitPopupContent(visit));
1012
  });
1013
+ // Route - use the dedicated function which handles highlighting (await to ensure geometries load)
1014
+ await renderRouteLines(highlightedVehicleId);
1015
 
1016
  // Summary
1017
  $("#score").text(solution.score ? `Score: ${solution.score}` : "Score: ?");
 
1024
  byVehicleItemData.clear();
1025
  byVisitItemData.clear();
1026
 
1027
+ // Build lookup maps for O(1) access
1028
  const vehicleById = new Map(routePlan.vehicles.map(v => [v.id, v]));
1029
  const visitById = new Map(routePlan.visits.map(v => [v.id, v]));
1030
  const visitOrderMap = new Map();
 
1345
  );
1346
  }
1347
 
1348
+ async function updateSolutionWithNewVisit(newSolution) {
1349
  loadedRoutePlan = newSolution;
1350
+ await renderRoutes(newSolution);
1351
  renderTimelines(newSolution);
1352
  $('#newVisitModal').modal('hide');
1353
  }
 
1382
  }
1383
 
1384
  function solve() {
1385
+ // Clear geometry cache - will be refreshed when solution updates
1386
+ routeGeometries = null;
1387
+
1388
  $.ajax({
1389
  url: "/route-plans",
1390
  type: "POST",
 
1426
  }
1427
  }
1428
 
1429
+ async function refreshRoutePlan() {
1430
  let path = "/route-plans/" + scheduleId;
1431
+ let isLoadingDemoData = scheduleId === null;
1432
+
1433
+ if (isLoadingDemoData) {
1434
  if (demoDataId === null) {
1435
  alert("Please select a test data set.");
1436
  return;
1437
  }
1438
 
1439
+ // Clear geometry cache when loading new demo data
1440
+ routeGeometries = null;
1441
+
1442
+ // Use SSE streaming for demo data loading to show progress
1443
+ try {
1444
+ const routePlan = await loadDemoDataWithProgress(demoDataId);
1445
+ loadedRoutePlan = routePlan;
1446
+ refreshSolvingButtons(
1447
+ routePlan.solverStatus != null &&
1448
+ routePlan.solverStatus !== "NOT_SOLVING",
1449
+ );
1450
+ await renderRoutes(routePlan);
1451
+ renderTimelines(routePlan);
1452
+ initialized = true;
1453
+ } catch (error) {
1454
+ showError("Getting demo data has failed: " + error.message, {});
1455
+ refreshSolvingButtons(false);
1456
+ }
1457
+ return;
1458
  }
1459
 
1460
+ // Loading existing route plan (during solving)
1461
+ try {
1462
+ const routePlan = await $.getJSON(path);
1463
  loadedRoutePlan = routePlan;
1464
  refreshSolvingButtons(
1465
  routePlan.solverStatus != null &&
1466
  routePlan.solverStatus !== "NOT_SOLVING",
1467
  );
1468
+ await renderRoutes(routePlan);
1469
  renderTimelines(routePlan);
1470
  initialized = true;
1471
+ } catch (error) {
1472
+ showError("Getting route plan has failed.", error);
1473
  refreshSolvingButtons(false);
1474
+ }
1475
  }
1476
 
1477
  function stopSolving() {
 
1568
  </ul>
1569
  </div>
1570
  <div class="ms-auto d-flex align-items-center gap-3">
1571
+ <div class="form-check form-switch d-flex align-items-center" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Enable real road routing using OpenStreetMap data. Slower initial load (~5-15s for download), but shows accurate road routes instead of straight lines.">
1572
+ <input class="form-check-input" type="checkbox" id="realRoadRouting" style="width: 2.5em; height: 1.25em; cursor: pointer;">
1573
+ <label class="form-check-label ms-2" for="realRoadRouting" style="white-space: nowrap; cursor: pointer;">
1574
+ <i class="fas fa-road"></i> Real Roads
1575
+ </label>
1576
+ </div>
1577
  <div class="dropdown">
1578
  <button class="btn dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" style="background-color: #10b981; color: #ffffff; border-color: #10b981;">
1579
  Data
static/index.html CHANGED
@@ -111,7 +111,7 @@
111
  opacity: 0;
112
  }
113
 
114
- /* Timeline enhancements */
115
  .timeline-stop-badge {
116
  background-color: #6366f1;
117
  color: white;
@@ -135,6 +135,47 @@
135
  .vis-labelset .vis-label {
136
  padding: 4px 8px;
137
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  </style>
139
  </head>
140
  <body>
@@ -400,6 +441,19 @@
400
  </div>
401
  </div>
402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  <footer id="solverforge-auto-footer"></footer>
404
 
405
  <script src="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.js"></script>
 
111
  opacity: 0;
112
  }
113
 
114
+ /* Timeline stop badges */
115
  .timeline-stop-badge {
116
  background-color: #6366f1;
117
  color: white;
 
135
  .vis-labelset .vis-label {
136
  padding: 4px 8px;
137
  }
138
+
139
+ /* Loading overlay */
140
+ .loading-overlay {
141
+ position: fixed;
142
+ top: 0;
143
+ left: 0;
144
+ right: 0;
145
+ bottom: 0;
146
+ background: rgba(255, 255, 255, 0.95);
147
+ display: flex;
148
+ align-items: center;
149
+ justify-content: center;
150
+ z-index: 2000;
151
+ transition: opacity 0.3s ease;
152
+ }
153
+ .loading-overlay.hidden {
154
+ opacity: 0;
155
+ pointer-events: none;
156
+ }
157
+ .loading-content {
158
+ text-align: center;
159
+ padding: 2rem;
160
+ }
161
+ .loading-spinner {
162
+ width: 60px;
163
+ height: 60px;
164
+ border: 4px solid #e5e7eb;
165
+ border-top-color: #10b981;
166
+ border-radius: 50%;
167
+ animation: spin 1s linear infinite;
168
+ margin: 0 auto 1.5rem;
169
+ }
170
+ @keyframes spin {
171
+ to { transform: rotate(360deg); }
172
+ }
173
+
174
+ /* Real Roads toggle styling */
175
+ #realRoadRouting:checked {
176
+ background-color: #10b981;
177
+ border-color: #10b981;
178
+ }
179
  </style>
180
  </head>
181
  <body>
 
441
  </div>
442
  </div>
443
 
444
+ <!-- Loading/Progress Overlay -->
445
+ <div id="loadingOverlay" class="loading-overlay hidden">
446
+ <div class="loading-content">
447
+ <div class="loading-spinner"></div>
448
+ <h5 id="loadingTitle">Loading Demo Data</h5>
449
+ <p id="loadingMessage" class="text-muted mb-2">Initializing...</p>
450
+ <div class="progress" style="width: 300px; height: 8px;">
451
+ <div id="loadingProgress" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div>
452
+ </div>
453
+ <small id="loadingDetail" class="text-muted mt-2 d-block"></small>
454
+ </div>
455
+ </div>
456
+
457
  <footer id="solverforge-auto-footer"></footer>
458
 
459
  <script src="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.js"></script>
tests/.pytest_cache/.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Created by pytest automatically.
2
+ *
tests/.pytest_cache/CACHEDIR.TAG ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ Signature: 8a477f597d28d172789f06886806bc55
2
+ # This file is a cache directory tag created by pytest.
3
+ # For information about cache directory tags, see:
4
+ # https://bford.info/cachedir/spec.html
tests/.pytest_cache/README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # pytest cache directory #
2
+
3
+ This directory contains data from the pytest's cache plugin,
4
+ which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
5
+
6
+ **Do not** commit this to version control.
7
+
8
+ See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
tests/.pytest_cache/v/cache/lastfailed ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "test_constraints.py": true,
3
+ "test_feasible.py": true
4
+ }
tests/.pytest_cache/v/cache/nodeids ADDED
@@ -0,0 +1 @@
 
 
1
+ []
tests/.pytest_cache/v/cache/stepwise ADDED
@@ -0,0 +1 @@
 
 
1
+ []
tests/__pycache__/test_constraints.cpython-312-pytest-8.2.2.pyc ADDED
Binary file (5.24 kB). View file
 
tests/__pycache__/test_demo_data.cpython-312-pytest-8.2.2.pyc ADDED
Binary file (52.1 kB). View file
 
tests/__pycache__/test_feasible.cpython-312-pytest-8.2.2.pyc ADDED
Binary file (4.4 kB). View file
 
tests/__pycache__/test_haversine.cpython-312-pytest-8.2.2.pyc ADDED
Binary file (23.6 kB). View file
 
tests/__pycache__/test_routing.cpython-312-pytest-8.2.2.pyc ADDED
Binary file (58.2 kB). View file
 
tests/__pycache__/test_timeline_fields.cpython-312-pytest-8.2.2.pyc ADDED
Binary file (21.7 kB). View file
 
tests/test_demo_data.py CHANGED
@@ -237,16 +237,16 @@ class TestHaversineIntegration:
237
  """Tests verifying Haversine distance is used correctly in demo data."""
238
 
239
  def test_philadelphia_diagonal_realistic(self):
240
- """Philadelphia area diagonal should be ~150km with Haversine."""
241
  props = DemoData.PHILADELPHIA.value
242
  diagonal_seconds = props.south_west_corner.driving_time_to(
243
  props.north_east_corner
244
  )
245
  diagonal_km = (diagonal_seconds / 3600) * 50 # 50 km/h average
246
 
247
- # Philadelphia area is roughly 100km x 170km
248
- # Diagonal should be around 150-200km
249
- assert 100 < diagonal_km < 250, f"Diagonal {diagonal_km}km seems wrong"
250
 
251
  def test_firenze_diagonal_realistic(self):
252
  """Firenze area diagonal should be ~10km with Haversine."""
 
237
  """Tests verifying Haversine distance is used correctly in demo data."""
238
 
239
  def test_philadelphia_diagonal_realistic(self):
240
+ """Philadelphia area diagonal should be ~15km with Haversine (tightened bbox)."""
241
  props = DemoData.PHILADELPHIA.value
242
  diagonal_seconds = props.south_west_corner.driving_time_to(
243
  props.north_east_corner
244
  )
245
  diagonal_km = (diagonal_seconds / 3600) * 50 # 50 km/h average
246
 
247
+ # Philadelphia bbox is tightened to Center City area (~8km x 12km)
248
+ # Diagonal should be around 10-20km
249
+ assert 8 < diagonal_km < 25, f"Diagonal {diagonal_km}km seems wrong"
250
 
251
  def test_firenze_diagonal_realistic(self):
252
  """Firenze area diagonal should be ~10km with Haversine."""
tests/test_haversine.py CHANGED
@@ -4,12 +4,7 @@ Unit tests for the Haversine driving time calculator in Location class.
4
  These tests verify that the driving time calculations correctly implement
5
  the Haversine formula for great-circle distance on Earth.
6
  """
7
- from vehicle_routing.domain import (
8
- Location,
9
- init_driving_time_matrix,
10
- clear_driving_time_matrix,
11
- is_driving_time_matrix_initialized,
12
- )
13
 
14
 
15
  class TestHaversineDrivingTime:
@@ -159,97 +154,3 @@ class TestHaversineInternalMethods:
159
  assert seconds == 72
160
 
161
 
162
- class TestPrecomputedMatrix:
163
- """Tests for the pre-computed driving time matrix functionality."""
164
-
165
- def setup_method(self):
166
- """Clear matrix before each test."""
167
- clear_driving_time_matrix()
168
-
169
- def teardown_method(self):
170
- """Clear matrix after each test."""
171
- clear_driving_time_matrix()
172
-
173
- def test_matrix_initially_empty(self):
174
- """Matrix should be empty on startup."""
175
- clear_driving_time_matrix()
176
- assert not is_driving_time_matrix_initialized()
177
-
178
- def test_init_matrix_marks_as_initialized(self):
179
- """Initializing matrix should mark it as initialized."""
180
- locations = [
181
- Location(latitude=0, longitude=0),
182
- Location(latitude=1, longitude=1),
183
- ]
184
- init_driving_time_matrix(locations)
185
- assert is_driving_time_matrix_initialized()
186
-
187
- def test_clear_matrix_marks_as_not_initialized(self):
188
- """Clearing matrix should mark it as not initialized."""
189
- locations = [
190
- Location(latitude=0, longitude=0),
191
- Location(latitude=1, longitude=1),
192
- ]
193
- init_driving_time_matrix(locations)
194
- clear_driving_time_matrix()
195
- assert not is_driving_time_matrix_initialized()
196
-
197
- def test_precomputed_returns_same_as_on_demand(self):
198
- """Pre-computed values should match on-demand calculations."""
199
- loc1 = Location(latitude=39.95, longitude=-75.17)
200
- loc2 = Location(latitude=40.71, longitude=-74.01)
201
- loc3 = Location(latitude=41.76, longitude=-72.68)
202
-
203
- # Calculate on-demand first
204
- on_demand_1_2 = loc1.driving_time_to(loc2)
205
- on_demand_2_3 = loc2.driving_time_to(loc3)
206
- on_demand_1_3 = loc1.driving_time_to(loc3)
207
-
208
- # Initialize matrix
209
- init_driving_time_matrix([loc1, loc2, loc3])
210
-
211
- # Calculate with matrix
212
- precomputed_1_2 = loc1.driving_time_to(loc2)
213
- precomputed_2_3 = loc2.driving_time_to(loc3)
214
- precomputed_1_3 = loc1.driving_time_to(loc3)
215
-
216
- # Should be identical
217
- assert precomputed_1_2 == on_demand_1_2
218
- assert precomputed_2_3 == on_demand_2_3
219
- assert precomputed_1_3 == on_demand_1_3
220
-
221
- def test_fallback_to_on_demand_for_unknown_location(self):
222
- """Locations not in matrix should calculate on-demand."""
223
- loc1 = Location(latitude=0, longitude=0)
224
- loc2 = Location(latitude=1, longitude=1)
225
- loc3 = Location(latitude=2, longitude=2) # Not in matrix
226
-
227
- # Initialize matrix with only loc1 and loc2
228
- init_driving_time_matrix([loc1, loc2])
229
-
230
- # loc3 is not in matrix, should fall back to on-demand
231
- driving_time = loc1.driving_time_to(loc3)
232
-
233
- # Should still calculate correctly (on-demand)
234
- expected = loc1._calculate_driving_time_haversine(loc3)
235
- assert driving_time == expected
236
-
237
- def test_matrix_size_is_n_squared(self):
238
- """Matrix should contain n² entries for n locations."""
239
- import vehicle_routing.domain as domain_module
240
-
241
- locations = [
242
- Location(latitude=0, longitude=0),
243
- Location(latitude=1, longitude=1),
244
- Location(latitude=2, longitude=2),
245
- ]
246
- init_driving_time_matrix(locations)
247
-
248
- # 3 locations = 9 entries (including self-to-self)
249
- assert len(domain_module._DRIVING_TIME_MATRIX) == 9
250
-
251
- def test_self_to_self_is_zero(self):
252
- """Matrix should have 0 for same location."""
253
- loc = Location(latitude=40.0, longitude=-75.0)
254
- init_driving_time_matrix([loc])
255
- assert loc.driving_time_to(loc) == 0
 
4
  These tests verify that the driving time calculations correctly implement
5
  the Haversine formula for great-circle distance on Earth.
6
  """
7
+ from vehicle_routing.domain import Location
 
 
 
 
 
8
 
9
 
10
  class TestHaversineDrivingTime:
 
154
  assert seconds == 72
155
 
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_routing.py ADDED
@@ -0,0 +1,431 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for the routing module.
3
+
4
+ Tests cover:
5
+ - RouteResult dataclass
6
+ - DistanceMatrix operations
7
+ - Haversine fallback calculations
8
+ - Polyline encoding/decoding roundtrip
9
+ - Location class integration with distance matrix
10
+ """
11
+ import pytest
12
+ import polyline
13
+
14
+ from vehicle_routing.domain import Location
15
+ from vehicle_routing.routing import (
16
+ RouteResult,
17
+ DistanceMatrix,
18
+ _haversine_driving_time,
19
+ _haversine_distance_meters,
20
+ _straight_line_geometry,
21
+ compute_distance_matrix_with_progress,
22
+ )
23
+
24
+
25
+ class TestRouteResult:
26
+ """Tests for the RouteResult dataclass."""
27
+
28
+ def test_create_route_result(self):
29
+ """Test creating a basic RouteResult."""
30
+ result = RouteResult(
31
+ duration_seconds=3600,
32
+ distance_meters=50000,
33
+ geometry="encodedPolyline"
34
+ )
35
+ assert result.duration_seconds == 3600
36
+ assert result.distance_meters == 50000
37
+ assert result.geometry == "encodedPolyline"
38
+
39
+ def test_route_result_optional_geometry(self):
40
+ """Test RouteResult with no geometry."""
41
+ result = RouteResult(duration_seconds=100, distance_meters=1000)
42
+ assert result.geometry is None
43
+
44
+
45
+ class TestDistanceMatrix:
46
+ """Tests for the DistanceMatrix class."""
47
+
48
+ def test_empty_matrix(self):
49
+ """Test empty distance matrix returns None."""
50
+ matrix = DistanceMatrix()
51
+ loc1 = Location(latitude=40.0, longitude=-75.0)
52
+ loc2 = Location(latitude=41.0, longitude=-74.0)
53
+ assert matrix.get_route(loc1, loc2) is None
54
+
55
+ def test_set_and_get_route(self):
56
+ """Test setting and retrieving a route."""
57
+ matrix = DistanceMatrix()
58
+ loc1 = Location(latitude=40.0, longitude=-75.0)
59
+ loc2 = Location(latitude=41.0, longitude=-74.0)
60
+
61
+ result = RouteResult(
62
+ duration_seconds=3600,
63
+ distance_meters=100000,
64
+ geometry="test_geometry"
65
+ )
66
+ matrix.set_route(loc1, loc2, result)
67
+
68
+ retrieved = matrix.get_route(loc1, loc2)
69
+ assert retrieved is not None
70
+ assert retrieved.duration_seconds == 3600
71
+ assert retrieved.distance_meters == 100000
72
+ assert retrieved.geometry == "test_geometry"
73
+
74
+ def test_get_route_different_direction(self):
75
+ """Test that routes are directional (A->B != B->A by default)."""
76
+ matrix = DistanceMatrix()
77
+ loc1 = Location(latitude=40.0, longitude=-75.0)
78
+ loc2 = Location(latitude=41.0, longitude=-74.0)
79
+
80
+ result = RouteResult(duration_seconds=3600, distance_meters=100000)
81
+ matrix.set_route(loc1, loc2, result)
82
+
83
+ # Should find loc1 -> loc2
84
+ assert matrix.get_route(loc1, loc2) is not None
85
+ # Should NOT find loc2 -> loc1 (wasn't set)
86
+ assert matrix.get_route(loc2, loc1) is None
87
+
88
+ def test_get_driving_time_from_matrix(self):
89
+ """Test getting driving time from matrix."""
90
+ matrix = DistanceMatrix()
91
+ loc1 = Location(latitude=40.0, longitude=-75.0)
92
+ loc2 = Location(latitude=41.0, longitude=-74.0)
93
+
94
+ result = RouteResult(duration_seconds=3600, distance_meters=100000)
95
+ matrix.set_route(loc1, loc2, result)
96
+
97
+ assert matrix.get_driving_time(loc1, loc2) == 3600
98
+
99
+ def test_get_driving_time_falls_back_to_haversine(self):
100
+ """Test that missing routes fall back to haversine."""
101
+ matrix = DistanceMatrix()
102
+ loc1 = Location(latitude=40.0, longitude=-75.0)
103
+ loc2 = Location(latitude=41.0, longitude=-74.0)
104
+
105
+ # Don't set any route - should use haversine fallback
106
+ time = matrix.get_driving_time(loc1, loc2)
107
+ assert time > 0 # Should return some positive value from haversine
108
+
109
+ def test_get_geometry(self):
110
+ """Test getting geometry from matrix."""
111
+ matrix = DistanceMatrix()
112
+ loc1 = Location(latitude=40.0, longitude=-75.0)
113
+ loc2 = Location(latitude=41.0, longitude=-74.0)
114
+
115
+ result = RouteResult(
116
+ duration_seconds=3600,
117
+ distance_meters=100000,
118
+ geometry="test_encoded_polyline"
119
+ )
120
+ matrix.set_route(loc1, loc2, result)
121
+
122
+ assert matrix.get_geometry(loc1, loc2) == "test_encoded_polyline"
123
+
124
+ def test_get_geometry_missing_returns_none(self):
125
+ """Test that missing routes return None for geometry."""
126
+ matrix = DistanceMatrix()
127
+ loc1 = Location(latitude=40.0, longitude=-75.0)
128
+ loc2 = Location(latitude=41.0, longitude=-74.0)
129
+
130
+ assert matrix.get_geometry(loc1, loc2) is None
131
+
132
+
133
+ class TestHaversineFunctions:
134
+ """Tests for standalone haversine functions."""
135
+
136
+ def test_haversine_driving_time_same_location(self):
137
+ """Same location should return 0 driving time."""
138
+ loc = Location(latitude=40.0, longitude=-75.0)
139
+ assert _haversine_driving_time(loc, loc) == 0
140
+
141
+ def test_haversine_driving_time_realistic(self):
142
+ """Test haversine driving time with realistic coordinates."""
143
+ philadelphia = Location(latitude=39.95, longitude=-75.17)
144
+ new_york = Location(latitude=40.71, longitude=-74.01)
145
+ time = _haversine_driving_time(philadelphia, new_york)
146
+ # ~130 km at 50 km/h = ~9400 seconds
147
+ assert 8500 < time < 10500
148
+
149
+ def test_haversine_distance_meters_same_location(self):
150
+ """Same location should return 0 distance."""
151
+ loc = Location(latitude=40.0, longitude=-75.0)
152
+ assert _haversine_distance_meters(loc, loc) == 0
153
+
154
+ def test_haversine_distance_meters_one_degree(self):
155
+ """Test one degree of latitude is approximately 111 km."""
156
+ loc1 = Location(latitude=0, longitude=0)
157
+ loc2 = Location(latitude=1, longitude=0)
158
+ distance = _haversine_distance_meters(loc1, loc2)
159
+ # 1 degree latitude = ~111.32 km
160
+ assert 110000 < distance < 113000
161
+
162
+ def test_straight_line_geometry(self):
163
+ """Test straight line geometry encoding."""
164
+ loc1 = Location(latitude=40.0, longitude=-75.0)
165
+ loc2 = Location(latitude=41.0, longitude=-74.0)
166
+ encoded = _straight_line_geometry(loc1, loc2)
167
+
168
+ # Decode and verify
169
+ points = polyline.decode(encoded)
170
+ assert len(points) == 2
171
+ assert abs(points[0][0] - 40.0) < 0.0001
172
+ assert abs(points[0][1] - (-75.0)) < 0.0001
173
+ assert abs(points[1][0] - 41.0) < 0.0001
174
+ assert abs(points[1][1] - (-74.0)) < 0.0001
175
+
176
+
177
+ class TestPolylineRoundtrip:
178
+ """Tests for polyline encoding/decoding."""
179
+
180
+ def test_encode_decode_roundtrip(self):
181
+ """Test that encoding and decoding preserves coordinates."""
182
+ coordinates = [(39.9526, -75.1652), (39.9535, -75.1589)]
183
+ encoded = polyline.encode(coordinates, precision=5)
184
+ decoded = polyline.decode(encoded, precision=5)
185
+
186
+ assert len(decoded) == 2
187
+ for orig, dec in zip(coordinates, decoded):
188
+ assert abs(orig[0] - dec[0]) < 0.00001
189
+ assert abs(orig[1] - dec[1]) < 0.00001
190
+
191
+ def test_encode_single_point(self):
192
+ """Test encoding a single point."""
193
+ coordinates = [(40.0, -75.0)]
194
+ encoded = polyline.encode(coordinates, precision=5)
195
+ decoded = polyline.decode(encoded, precision=5)
196
+
197
+ assert len(decoded) == 1
198
+ assert abs(decoded[0][0] - 40.0) < 0.00001
199
+ assert abs(decoded[0][1] - (-75.0)) < 0.00001
200
+
201
+ def test_encode_many_points(self):
202
+ """Test encoding many points (like a real route)."""
203
+ coordinates = [
204
+ (39.9526, -75.1652),
205
+ (39.9535, -75.1589),
206
+ (39.9543, -75.1690),
207
+ (39.9520, -75.1685),
208
+ (39.9505, -75.1660),
209
+ ]
210
+ encoded = polyline.encode(coordinates, precision=5)
211
+ decoded = polyline.decode(encoded, precision=5)
212
+
213
+ assert len(decoded) == len(coordinates)
214
+ for orig, dec in zip(coordinates, decoded):
215
+ assert abs(orig[0] - dec[0]) < 0.00001
216
+ assert abs(orig[1] - dec[1]) < 0.00001
217
+
218
+
219
+ class TestLocationDistanceMatrixIntegration:
220
+ """Tests for Location class integration with DistanceMatrix."""
221
+
222
+ def setup_method(self):
223
+ """Clear any existing distance matrix before each test."""
224
+ Location.clear_distance_matrix()
225
+
226
+ def teardown_method(self):
227
+ """Clear distance matrix after each test."""
228
+ Location.clear_distance_matrix()
229
+
230
+ def test_location_uses_haversine_without_matrix(self):
231
+ """Without matrix, Location should use haversine."""
232
+ loc1 = Location(latitude=40.0, longitude=-75.0)
233
+ loc2 = Location(latitude=41.0, longitude=-74.0)
234
+
235
+ # Should use haversine (no matrix set)
236
+ time = loc1.driving_time_to(loc2)
237
+ assert time > 0
238
+
239
+ def test_location_uses_matrix_when_set(self):
240
+ """With matrix set, Location should use matrix values."""
241
+ matrix = DistanceMatrix()
242
+ loc1 = Location(latitude=40.0, longitude=-75.0)
243
+ loc2 = Location(latitude=41.0, longitude=-74.0)
244
+
245
+ # Set a specific value in matrix
246
+ result = RouteResult(duration_seconds=12345, distance_meters=100000)
247
+ matrix.set_route(loc1, loc2, result)
248
+
249
+ # Set the matrix on Location class
250
+ Location.set_distance_matrix(matrix)
251
+
252
+ # Should return the matrix value, not haversine
253
+ time = loc1.driving_time_to(loc2)
254
+ assert time == 12345
255
+
256
+ def test_location_falls_back_when_route_not_in_matrix(self):
257
+ """If route not in matrix, Location should fall back to haversine."""
258
+ matrix = DistanceMatrix()
259
+ loc1 = Location(latitude=40.0, longitude=-75.0)
260
+ loc2 = Location(latitude=41.0, longitude=-74.0)
261
+ loc3 = Location(latitude=42.0, longitude=-73.0)
262
+
263
+ # Only set loc1 -> loc2
264
+ result = RouteResult(duration_seconds=12345, distance_meters=100000)
265
+ matrix.set_route(loc1, loc2, result)
266
+
267
+ Location.set_distance_matrix(matrix)
268
+
269
+ # loc1 -> loc2 should use matrix
270
+ assert loc1.driving_time_to(loc2) == 12345
271
+
272
+ # loc1 -> loc3 should fall back to haversine (not in matrix)
273
+ time = loc1.driving_time_to(loc3)
274
+ assert time != 12345 # Should be haversine calculated value
275
+ assert time > 0
276
+
277
+ def test_get_distance_matrix(self):
278
+ """Test getting the current distance matrix."""
279
+ assert Location.get_distance_matrix() is None
280
+
281
+ matrix = DistanceMatrix()
282
+ Location.set_distance_matrix(matrix)
283
+ assert Location.get_distance_matrix() is matrix
284
+
285
+ def test_clear_distance_matrix(self):
286
+ """Test clearing the distance matrix."""
287
+ matrix = DistanceMatrix()
288
+ Location.set_distance_matrix(matrix)
289
+ assert Location.get_distance_matrix() is not None
290
+
291
+ Location.clear_distance_matrix()
292
+ assert Location.get_distance_matrix() is None
293
+
294
+
295
+ class TestDistanceMatrixSameLocation:
296
+ """Tests for handling same-location routes."""
297
+
298
+ def test_same_location_zero_time(self):
299
+ """Same location should have zero driving time."""
300
+ loc = Location(latitude=40.0, longitude=-75.0)
301
+
302
+ matrix = DistanceMatrix()
303
+ result = RouteResult(
304
+ duration_seconds=0,
305
+ distance_meters=0,
306
+ geometry=polyline.encode([(40.0, -75.0)], precision=5)
307
+ )
308
+ matrix.set_route(loc, loc, result)
309
+
310
+ assert matrix.get_driving_time(loc, loc) == 0
311
+
312
+
313
+ class TestComputeDistanceMatrixWithProgress:
314
+ """Tests for the compute_distance_matrix_with_progress function."""
315
+
316
+ def test_empty_locations_returns_empty_matrix(self):
317
+ """Empty location list should return empty matrix."""
318
+ matrix = compute_distance_matrix_with_progress([], use_osm=False)
319
+ assert matrix is not None
320
+ # Empty matrix - no routes to check
321
+
322
+ def test_haversine_mode_computes_all_pairs(self):
323
+ """Haversine mode should compute all location pairs."""
324
+ locations = [
325
+ Location(latitude=40.0, longitude=-75.0),
326
+ Location(latitude=41.0, longitude=-74.0),
327
+ Location(latitude=42.0, longitude=-73.0),
328
+ ]
329
+ matrix = compute_distance_matrix_with_progress(
330
+ locations, use_osm=False
331
+ )
332
+
333
+ # Should have all 9 pairs (3x3)
334
+ for origin in locations:
335
+ for dest in locations:
336
+ result = matrix.get_route(origin, dest)
337
+ assert result is not None
338
+ if origin is dest:
339
+ assert result.duration_seconds == 0
340
+ assert result.distance_meters == 0
341
+ else:
342
+ assert result.duration_seconds > 0
343
+ assert result.distance_meters > 0
344
+ assert result.geometry is not None
345
+
346
+ def test_progress_callback_is_called(self):
347
+ """Progress callback should be called during computation."""
348
+ locations = [
349
+ Location(latitude=40.0, longitude=-75.0),
350
+ Location(latitude=41.0, longitude=-74.0),
351
+ ]
352
+
353
+ progress_calls = []
354
+
355
+ def callback(phase, message, percent, detail=""):
356
+ progress_calls.append({
357
+ "phase": phase,
358
+ "message": message,
359
+ "percent": percent,
360
+ "detail": detail
361
+ })
362
+
363
+ compute_distance_matrix_with_progress(
364
+ locations, use_osm=False, progress_callback=callback
365
+ )
366
+
367
+ # Should have received progress callbacks
368
+ assert len(progress_calls) > 0
369
+
370
+ # Should have a "complete" phase at the end
371
+ assert any(p["phase"] == "complete" for p in progress_calls)
372
+
373
+ # All percentages should be between 0 and 100
374
+ for call in progress_calls:
375
+ assert 0 <= call["percent"] <= 100
376
+
377
+ def test_haversine_mode_skips_network_phase(self):
378
+ """In haversine mode, should not have network download messages."""
379
+ locations = [
380
+ Location(latitude=40.0, longitude=-75.0),
381
+ Location(latitude=41.0, longitude=-74.0),
382
+ ]
383
+
384
+ progress_calls = []
385
+
386
+ def callback(phase, message, percent, detail=""):
387
+ progress_calls.append({
388
+ "phase": phase,
389
+ "message": message
390
+ })
391
+
392
+ compute_distance_matrix_with_progress(
393
+ locations, use_osm=False, progress_callback=callback
394
+ )
395
+
396
+ # Should have a "network" phase but with haversine message
397
+ network_messages = [p for p in progress_calls if p["phase"] == "network"]
398
+ assert len(network_messages) > 0
399
+ assert "haversine" in network_messages[0]["message"].lower()
400
+
401
+ def test_bbox_is_used_when_provided(self):
402
+ """Provided bounding box should be used."""
403
+ locations = [
404
+ Location(latitude=40.0, longitude=-75.0),
405
+ Location(latitude=41.0, longitude=-74.0),
406
+ ]
407
+
408
+ bbox = (42.0, 39.0, -73.0, -76.0) # north, south, east, west
409
+
410
+ # Should complete without error with provided bbox
411
+ matrix = compute_distance_matrix_with_progress(
412
+ locations, bbox=bbox, use_osm=False
413
+ )
414
+ assert matrix is not None
415
+
416
+ def test_geometries_are_straight_lines_in_haversine_mode(self):
417
+ """In haversine mode, geometries should be straight lines."""
418
+ loc1 = Location(latitude=40.0, longitude=-75.0)
419
+ loc2 = Location(latitude=41.0, longitude=-74.0)
420
+
421
+ matrix = compute_distance_matrix_with_progress(
422
+ [loc1, loc2], use_osm=False
423
+ )
424
+
425
+ result = matrix.get_route(loc1, loc2)
426
+ assert result is not None
427
+ assert result.geometry is not None
428
+
429
+ # Decode and verify it's a straight line (2 points)
430
+ points = polyline.decode(result.geometry)
431
+ assert len(points) == 2