|
2 | 2 | Tests for the modern routing API. |
3 | 3 | """ |
4 | 4 |
|
| 5 | +import itertools |
5 | 6 | import unittest |
6 | 7 |
|
7 | 8 | import numpy as np |
8 | 9 | import pandas as pd |
9 | 10 |
|
10 | 11 | from allocator.api import shortest_path, tsp_christofides, tsp_ortools |
11 | 12 | from allocator.api.types import RouteResult |
| 13 | +from allocator.distances import get_distance_matrix |
12 | 14 |
|
13 | 15 |
|
14 | 16 | class TestRouteAPI(unittest.TestCase): |
@@ -84,12 +86,70 @@ def test_high_level_shortest_path_function(self): |
84 | 86 | except ImportError: |
85 | 87 | self.skipTest("OR-Tools not available") |
86 | 88 |
|
87 | | - def test_christofides_import_error_handling(self): |
88 | | - """Test Christofides handles missing dependencies gracefully.""" |
89 | | - with self.assertRaises(ImportError) as cm: |
90 | | - tsp_christofides(self.test_points) |
| 89 | + def test_christofides_solves_a_tour(self): |
| 90 | + """Christofides returns a tour that visits every point exactly once. |
| 91 | +
|
| 92 | + This replaces ``test_christofides_import_error_handling``, which asserted |
| 93 | + that ``tsp_christofides`` *raises* ImportError. That only happens when the |
| 94 | + ``algorithms`` extra is absent, so the test meant one thing in the plain |
| 95 | + test job and the opposite in test-algorithms -- and it passed only |
| 96 | + because nobody had installed the extra on that runner. |
| 97 | +
|
| 98 | + Running it the other way is what showed the function had never worked on |
| 99 | + Python 3: the Christofides package on PyPI is Python 2 source and raises |
| 100 | + SyntaxError on import. It now uses networkx's approximation, which is a |
| 101 | + hard dependency already, so there is no optional import left to test. |
| 102 | + """ |
| 103 | + result = tsp_christofides(self.test_points) |
| 104 | + n_points = len(self.test_points) |
| 105 | + |
| 106 | + self.assertIsInstance(result, RouteResult) |
| 107 | + # Closed tour, matching tsp_ortools: every point once, then back to the |
| 108 | + # start. The two solvers are interchangeable through shortest_path(), so |
| 109 | + # they must agree on what a route is. |
| 110 | + self.assertEqual(len(result.route), n_points + 1) |
| 111 | + self.assertEqual(result.route[0], result.route[-1]) |
| 112 | + self.assertEqual(sorted(result.route[:-1]), list(range(n_points))) |
| 113 | + self.assertGreater(result.total_distance, 0) |
| 114 | + |
| 115 | + def test_christofides_stays_within_its_approximation_guarantee(self): |
| 116 | + """The property that makes Christofides worth using over any other tour. |
| 117 | +
|
| 118 | + It is a 3/2-approximation on a metric instance, so on a problem small |
| 119 | + enough to solve exactly the tour must be no worse than 1.5x optimal. A |
| 120 | + merely "valid" tour -- every point once, positive length -- would also be |
| 121 | + produced by visiting the points in input order, so without this the test |
| 122 | + above does not distinguish the algorithm from doing nothing. |
| 123 | + """ |
| 124 | + result = tsp_christofides(self.test_points) |
| 125 | + |
| 126 | + # Score the tour in the metric the solver actually optimised in. |
| 127 | + # `euclidean` projects lon/lat to UTM metres before measuring |
| 128 | + # (allocator/distances/euclidean.py, utm.from_latlon), so scoring with |
| 129 | + # distances computed from raw degrees would compare a UTM-optimal tour |
| 130 | + # against a degree-optimal one. That is a different problem, and its |
| 131 | + # ratio can exceed 3/2 through projection distortion alone. |
| 132 | + points = self.test_points[["longitude", "latitude"]].to_numpy() |
| 133 | + n = len(points) |
| 134 | + distances = get_distance_matrix(points, points, method="euclidean") |
| 135 | + |
| 136 | + optimal = min( |
| 137 | + sum(distances[order[i], order[i + 1]] for i in range(n - 1)) |
| 138 | + + distances[order[-1], order[0]] |
| 139 | + for order in itertools.permutations(range(n)) |
| 140 | + ) |
| 141 | + # result.route is the closed tour; drop the repeated start to iterate. |
| 142 | + route = result.route[:-1] |
| 143 | + tour = ( |
| 144 | + sum(distances[route[i], route[i + 1]] for i in range(n - 1)) |
| 145 | + + distances[route[-1], route[0]] |
| 146 | + ) |
91 | 147 |
|
92 | | - self.assertIn("Christofides", str(cm.exception)) |
| 148 | + self.assertLessEqual( |
| 149 | + tour, |
| 150 | + 1.5 * optimal, |
| 151 | + f"tour {tour:.4f} exceeds 1.5x the optimum {optimal:.4f}", |
| 152 | + ) |
93 | 153 |
|
94 | 154 | def test_invalid_method(self): |
95 | 155 | """Test error handling for invalid TSP method.""" |
|
0 commit comments