blackopsrepl/vehicle-routing-python
2
1"""2Tests for demo data generation with customer-type based time windows.3 4These tests verify that the demo data correctly generates realistic5delivery scenarios with customer types driving time windows and demand.6"""7import pytest8from datetime import time9 10from vehicle_routing.demo_data import (11 DemoData,12 generate_demo_data,13 CustomerType,14 random_customer_type,15 CUSTOMER_TYPE_WEIGHTS,16)17from random import Random18 19 20class TestCustomerTypes:21 """Tests for customer type definitions and selection."""22 23 def test_customer_types_have_valid_time_windows(self):24 """Each customer type should have a valid time window."""25 for ctype in CustomerType:26 assert ctype.window_start < ctype.window_end, (27 f"{ctype.name} window_start should be before window_end"28 )29 30 def test_customer_types_have_valid_demand_ranges(self):31 """Each customer type should have valid demand ranges."""32 for ctype in CustomerType:33 assert ctype.min_demand >= 1, f"{ctype.name} min_demand should be >= 1"34 assert ctype.max_demand >= ctype.min_demand, (35 f"{ctype.name} max_demand should be >= min_demand"36 )37 38 def test_customer_types_have_valid_service_duration_ranges(self):39 """Each customer type should have valid service duration ranges."""40 for ctype in CustomerType:41 assert ctype.min_service_minutes >= 1, (42 f"{ctype.name} min_service_minutes should be >= 1"43 )44 assert ctype.max_service_minutes >= ctype.min_service_minutes, (45 f"{ctype.name} max_service_minutes should be >= min_service_minutes"46 )47 48 def test_residential_time_window(self):49 """Residential customers have evening windows."""50 res = CustomerType.RESIDENTIAL51 assert res.window_start == time(17, 0)52 assert res.window_end == time(20, 0)53 54 def test_business_time_window(self):55 """Business customers have standard business hours."""56 biz = CustomerType.BUSINESS57 assert biz.window_start == time(9, 0)58 assert biz.window_end == time(17, 0)59 60 def test_restaurant_time_window(self):61 """Restaurant customers have early morning windows."""62 rest = CustomerType.RESTAURANT63 assert rest.window_start == time(6, 0)64 assert rest.window_end == time(10, 0)65 66 def test_weighted_selection_distribution(self):67 """Weighted selection should roughly match configured weights."""68 random = Random(42)69 counts = {ctype: 0 for ctype in CustomerType}70 71 n_samples = 1000072 for _ in range(n_samples):73 ctype = random_customer_type(random)74 counts[ctype] += 175 76 # Expected: 50% residential, 30% business, 20% restaurant77 total_weight = sum(w for _, w in CUSTOMER_TYPE_WEIGHTS)78 for ctype, weight in CUSTOMER_TYPE_WEIGHTS:79 expected_pct = weight / total_weight80 actual_pct = counts[ctype] / n_samples81 # Allow 5% tolerance82 assert abs(actual_pct - expected_pct) < 0.05, (83 f"{ctype.name}: expected {expected_pct:.2%}, got {actual_pct:.2%}"84 )85 86 87class TestDemoDataGeneration:88 """Tests for the demo data generation."""89 90 @pytest.mark.parametrize("demo", list(DemoData))91 def test_generates_correct_number_of_vehicles(self, demo):92 """Should generate the configured number of vehicles."""93 plan = generate_demo_data(demo)94 assert len(plan.vehicles) == demo.value.vehicle_count95 96 @pytest.mark.parametrize("demo", list(DemoData))97 def test_generates_correct_number_of_visits(self, demo):98 """Should generate the configured number of visits."""99 plan = generate_demo_data(demo)100 assert len(plan.visits) == demo.value.visit_count101 102 @pytest.mark.parametrize("demo", list(DemoData))103 def test_visits_have_valid_time_windows(self, demo):104 """All visits should have time windows matching customer types."""105 plan = generate_demo_data(demo)106 valid_windows = {107 (ctype.window_start, ctype.window_end) for ctype in CustomerType108 }109 110 for visit in plan.visits:111 window = (visit.min_start_time.time(), visit.max_end_time.time())112 assert window in valid_windows, (113 f"Visit {visit.id} has invalid window {window}"114 )115 116 @pytest.mark.parametrize("demo", list(DemoData))117 def test_visits_have_varied_time_windows(self, demo):118 """Visits should have a mix of different time windows."""119 plan = generate_demo_data(demo)120 121 windows = {122 (v.min_start_time.time(), v.max_end_time.time())123 for v in plan.visits124 }125 126 # Should have at least 2 different window types (likely all 3)127 assert len(windows) >= 2, "Should have varied time windows"128 129 @pytest.mark.parametrize("demo", list(DemoData))130 def test_vehicles_depart_at_6am(self, demo):131 """Vehicles should depart at 06:00 to serve restaurant customers."""132 plan = generate_demo_data(demo)133 134 for vehicle in plan.vehicles:135 assert vehicle.departure_time.hour == 6136 assert vehicle.departure_time.minute == 0137 138 @pytest.mark.parametrize("demo", list(DemoData))139 def test_visits_within_geographic_bounds(self, demo):140 """All visits should be within the specified geographic bounds."""141 plan = generate_demo_data(demo)142 sw = plan.south_west_corner143 ne = plan.north_east_corner144 145 for visit in plan.visits:146 assert sw.latitude <= visit.location.latitude <= ne.latitude, (147 f"Visit {visit.id} latitude {visit.location.latitude} "148 f"outside bounds [{sw.latitude}, {ne.latitude}]"149 )150 assert sw.longitude <= visit.location.longitude <= ne.longitude, (151 f"Visit {visit.id} longitude {visit.location.longitude} "152 f"outside bounds [{sw.longitude}, {ne.longitude}]"153 )154 155 @pytest.mark.parametrize("demo", list(DemoData))156 def test_vehicles_within_geographic_bounds(self, demo):157 """All vehicle home locations should be within geographic bounds."""158 plan = generate_demo_data(demo)159 sw = plan.south_west_corner160 ne = plan.north_east_corner161 162 for vehicle in plan.vehicles:163 loc = vehicle.home_location164 assert sw.latitude <= loc.latitude <= ne.latitude165 assert sw.longitude <= loc.longitude <= ne.longitude166 167 @pytest.mark.parametrize("demo", list(DemoData))168 def test_service_durations_match_customer_types(self, demo):169 """Service durations should match their customer type's service duration range."""170 plan = generate_demo_data(demo)171 172 # Map time windows back to customer types173 window_to_type = {174 (ctype.window_start, ctype.window_end): ctype175 for ctype in CustomerType176 }177 178 for visit in plan.visits:179 window = (visit.min_start_time.time(), visit.max_end_time.time())180 ctype = window_to_type[window]181 duration_minutes = int(visit.service_duration.total_seconds() / 60)182 assert ctype.min_service_minutes <= duration_minutes <= ctype.max_service_minutes, (183 f"Visit {visit.id} ({ctype.name}) service duration {duration_minutes}min "184 f"outside [{ctype.min_service_minutes}, {ctype.max_service_minutes}]"185 )186 187 @pytest.mark.parametrize("demo", list(DemoData))188 def test_demands_match_customer_types(self, demo):189 """Visit demands should match their customer type's demand range."""190 plan = generate_demo_data(demo)191 192 # Map time windows back to customer types193 window_to_type = {194 (ctype.window_start, ctype.window_end): ctype195 for ctype in CustomerType196 }197 198 for visit in plan.visits:199 window = (visit.min_start_time.time(), visit.max_end_time.time())200 ctype = window_to_type[window]201 assert ctype.min_demand <= visit.demand <= ctype.max_demand, (202 f"Visit {visit.id} ({ctype.name}) demand {visit.demand} "203 f"outside [{ctype.min_demand}, {ctype.max_demand}]"204 )205 206 @pytest.mark.parametrize("demo", list(DemoData))207 def test_vehicle_capacities_within_bounds(self, demo):208 """Vehicle capacities should be within configured bounds."""209 plan = generate_demo_data(demo)210 props = demo.value211 212 for vehicle in plan.vehicles:213 assert props.min_vehicle_capacity <= vehicle.capacity <= props.max_vehicle_capacity, (214 f"Vehicle {vehicle.id} capacity {vehicle.capacity} "215 f"outside [{props.min_vehicle_capacity}, {props.max_vehicle_capacity}]"216 )217 218 @pytest.mark.parametrize("demo", list(DemoData))219 def test_deterministic_with_same_seed(self, demo):220 """Same demo data should produce identical results (deterministic)."""221 plan1 = generate_demo_data(demo)222 plan2 = generate_demo_data(demo)223 224 assert len(plan1.visits) == len(plan2.visits)225 assert len(plan1.vehicles) == len(plan2.vehicles)226 227 for v1, v2 in zip(plan1.visits, plan2.visits):228 assert v1.location.latitude == v2.location.latitude229 assert v1.location.longitude == v2.location.longitude230 assert v1.demand == v2.demand231 assert v1.service_duration == v2.service_duration232 assert v1.min_start_time == v2.min_start_time233 assert v1.max_end_time == v2.max_end_time234 235 236class TestHaversineIntegration:237 """Tests verifying Haversine distance is used correctly in demo data."""238 239 def test_philadelphia_diagonal_realistic(self):240 """Philadelphia area diagonal should be ~15km with Haversine (tightened bbox)."""241 props = DemoData.PHILADELPHIA.value242 diagonal_seconds = props.south_west_corner.driving_time_to(243 props.north_east_corner244 )245 diagonal_km = (diagonal_seconds / 3600) * 50 # 50 km/h average246 247 # Philadelphia bbox is tightened to Center City area (~8km x 12km)248 # Diagonal should be around 10-20km249 assert 8 < diagonal_km < 25, f"Diagonal {diagonal_km}km seems wrong"250 251 def test_firenze_diagonal_realistic(self):252 """Firenze area diagonal should be ~10km with Haversine."""253 props = DemoData.FIRENZE.value254 diagonal_seconds = props.south_west_corner.driving_time_to(255 props.north_east_corner256 )257 diagonal_km = (diagonal_seconds / 3600) * 50 # 50 km/h average258 259 # Firenze area is small, roughly 6km x 12km260 assert 5 < diagonal_km < 20, f"Diagonal {diagonal_km}km seems wrong"261 262 def test_inter_visit_distances_use_haversine(self):263 """Distances between visits should use Haversine formula."""264 plan = generate_demo_data(DemoData.PHILADELPHIA)265 266 # Pick two visits267 v1, v2 = plan.visits[0], plan.visits[1]268 269 # Calculate distance using the Location method270 haversine_time = v1.location.driving_time_to(v2.location)271 272 # Verify it's not using simple Euclidean (which would be ~4000 * coord_diff)273 simple_euclidean = round(274 ((v1.location.latitude - v2.location.latitude) ** 2 +275 (v1.location.longitude - v2.location.longitude) ** 2) ** 0.5 * 4000276 )277 278 # Haversine should give different (usually larger) results279 # for geographic coordinates280 assert haversine_time != simple_euclidean or haversine_time == 0281 