Upload 66 files
Browse files- pom.xml +1 -0
- pyproject.toml +4 -0
- src/vehicle_routing/__init__.py +1 -2
- src/vehicle_routing/__pycache__/__init__.cpython-310.pyc +0 -0
- src/vehicle_routing/__pycache__/__init__.cpython-312.pyc +0 -0
- src/vehicle_routing/__pycache__/constraints.cpython-310.pyc +0 -0
- src/vehicle_routing/__pycache__/constraints.cpython-312.pyc +0 -0
- src/vehicle_routing/__pycache__/converters.cpython-310.pyc +0 -0
- src/vehicle_routing/__pycache__/converters.cpython-312.pyc +0 -0
- src/vehicle_routing/__pycache__/demo_data.cpython-310.pyc +0 -0
- src/vehicle_routing/__pycache__/demo_data.cpython-312.pyc +0 -0
- src/vehicle_routing/__pycache__/domain.cpython-310.pyc +0 -0
- src/vehicle_routing/__pycache__/domain.cpython-312.pyc +0 -0
- src/vehicle_routing/__pycache__/json_serialization.cpython-310.pyc +0 -0
- src/vehicle_routing/__pycache__/json_serialization.cpython-312.pyc +0 -0
- src/vehicle_routing/__pycache__/rest_api.cpython-310.pyc +0 -0
- src/vehicle_routing/__pycache__/rest_api.cpython-312.pyc +0 -0
- src/vehicle_routing/__pycache__/routing.cpython-312.pyc +0 -0
- src/vehicle_routing/__pycache__/score_analysis.cpython-310.pyc +0 -0
- src/vehicle_routing/__pycache__/score_analysis.cpython-312.pyc +0 -0
- src/vehicle_routing/__pycache__/solver.cpython-310.pyc +0 -0
- src/vehicle_routing/__pycache__/solver.cpython-312.pyc +0 -0
- src/vehicle_routing/demo_data.py +328 -62
- src/vehicle_routing/domain.py +28 -57
- src/vehicle_routing/rest_api.py +292 -9
- src/vehicle_routing/routing.py +622 -0
- static/app.js +236 -22
- static/index.html +55 -1
- tests/.pytest_cache/.gitignore +2 -0
- tests/.pytest_cache/CACHEDIR.TAG +4 -0
- tests/.pytest_cache/README.md +8 -0
- tests/.pytest_cache/v/cache/lastfailed +4 -0
- tests/.pytest_cache/v/cache/nodeids +1 -0
- tests/.pytest_cache/v/cache/stepwise +1 -0
- tests/__pycache__/test_constraints.cpython-312-pytest-8.2.2.pyc +0 -0
- tests/__pycache__/test_demo_data.cpython-312-pytest-8.2.2.pyc +0 -0
- tests/__pycache__/test_feasible.cpython-312-pytest-8.2.2.pyc +0 -0
- tests/__pycache__/test_haversine.cpython-312-pytest-8.2.2.pyc +0 -0
- tests/__pycache__/test_routing.cpython-312-pytest-8.2.2.pyc +0 -0
- tests/__pycache__/test_timeline_fields.cpython-312-pytest-8.2.2.pyc +0 -0
- tests/test_demo_data.py +4 -4
- tests/test_haversine.py +1 -100
- 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 |
-
|
| 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
|
| 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.
|
| 97 |
-
longitude=-
|
| 98 |
-
Location(latitude=40.
|
| 99 |
-
longitude=-
|
| 100 |
|
|
|
|
| 101 |
HARTFORT = _DemoDataProperties(1, 50, 6, time(6, 0),
|
| 102 |
20, 30,
|
| 103 |
-
Location(latitude=41.
|
| 104 |
-
longitude=-
|
| 105 |
-
Location(latitude=41.
|
| 106 |
-
longitude=-72.
|
| 107 |
|
|
|
|
| 108 |
FIRENZE = _DemoDataProperties(2, 77, 6, time(6, 0),
|
| 109 |
20, 40,
|
| 110 |
-
Location(latitude=43.
|
| 111 |
-
longitude=11.
|
| 112 |
-
Location(latitude=43.
|
| 113 |
-
longitude=11.
|
| 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
|
| 142 |
"""
|
| 143 |
-
Generate demo data for vehicle routing.
|
| 144 |
|
| 145 |
-
|
| 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 |
-
|
| 160 |
-
|
|
|
|
| 161 |
|
| 162 |
vehicle_capacities = ints(random, demo_data.min_vehicle_capacity,
|
| 163 |
demo_data.max_vehicle_capacity + 1)
|
| 164 |
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
|
|
|
| 179 |
visits = []
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
visits
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
)
|
| 193 |
-
)
|
| 194 |
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
else:
|
| 201 |
-
#
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 41 |
-
1.
|
| 42 |
-
2.
|
| 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 |
-
|
| 64 |
-
|
| 65 |
"""
|
| 66 |
-
|
| 67 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
"""
|
| 73 |
Get a specific demo data set.
|
| 74 |
|
| 75 |
Args:
|
| 76 |
demo_name: Name of the demo dataset (PHILADELPHIA, HARTFORT, FIRENZE)
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
| 80 |
"""
|
| 81 |
try:
|
| 82 |
demo_data = DemoData[demo_name]
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 1246 |
if (demoDataId === null) {
|
| 1247 |
alert("Please select a test data set.");
|
| 1248 |
return;
|
| 1249 |
}
|
| 1250 |
|
| 1251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1252 |
}
|
| 1253 |
|
| 1254 |
-
|
|
|
|
|
|
|
| 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 |
-
}
|
| 1264 |
-
showError("Getting route plan has failed.",
|
| 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
|
| 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 ~
|
| 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
|
| 248 |
-
# Diagonal should be around
|
| 249 |
-
assert
|
| 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
|