allocator 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- allocator/__init__.py +154 -0
- allocator/api/__init__.py +32 -0
- allocator/api/cluster.py +126 -0
- allocator/api/distance.py +225 -0
- allocator/api/route.py +256 -0
- allocator/api/types.py +52 -0
- allocator/cli/__init__.py +1 -0
- allocator/cli/cluster_cmd.py +104 -0
- allocator/cli/main.py +170 -0
- allocator/cli/route_cmd.py +164 -0
- allocator/core/__init__.py +1 -0
- allocator/core/algorithms.py +200 -0
- allocator/core/routing.py +242 -0
- allocator/distances/__init__.py +17 -0
- allocator/distances/euclidean.py +80 -0
- allocator/distances/external_apis.py +165 -0
- allocator/distances/factory.py +66 -0
- allocator/distances/haversine.py +43 -0
- allocator/io/__init__.py +1 -0
- allocator/io/data_handler.py +174 -0
- allocator/py.typed +2 -0
- allocator/utils.py +37 -0
- allocator/viz/__init__.py +17 -0
- allocator/viz/plotting.py +206 -0
- allocator-1.0.0.dist-info/METADATA +132 -0
- allocator-1.0.0.dist-info/RECORD +28 -0
- allocator-1.0.0.dist-info/WHEEL +4 -0
- allocator-1.0.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Routing CLI commands.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.command()
|
|
12
|
+
@click.argument("input_file", type=click.Path(exists=True))
|
|
13
|
+
@click.option(
|
|
14
|
+
"--method",
|
|
15
|
+
"-m",
|
|
16
|
+
default="christofides",
|
|
17
|
+
type=click.Choice(["christofides", "ortools", "osrm", "google"]),
|
|
18
|
+
help="TSP solving method",
|
|
19
|
+
)
|
|
20
|
+
@click.option(
|
|
21
|
+
"--distance",
|
|
22
|
+
"-d",
|
|
23
|
+
default="euclidean",
|
|
24
|
+
type=click.Choice(["euclidean", "haversine", "osrm", "google"]),
|
|
25
|
+
help="Distance metric to use",
|
|
26
|
+
)
|
|
27
|
+
@click.option("--output", "-o", type=click.Path(), help="Output file path")
|
|
28
|
+
@click.option(
|
|
29
|
+
"--format",
|
|
30
|
+
"output_format",
|
|
31
|
+
default="csv",
|
|
32
|
+
type=click.Choice(["csv", "json"]),
|
|
33
|
+
help="Output format",
|
|
34
|
+
)
|
|
35
|
+
@click.option("--api-key", help="API key for Google services")
|
|
36
|
+
@click.option("--osrm-base-url", help="Custom OSRM server URL")
|
|
37
|
+
@click.pass_context
|
|
38
|
+
def tsp(ctx, input_file, method, distance, output, output_format, api_key, osrm_base_url):
|
|
39
|
+
"""Solve Traveling Salesman Problem (TSP) for geographic points."""
|
|
40
|
+
from ..api import shortest_path
|
|
41
|
+
from ..io.data_handler import DataHandler
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
# Prepare kwargs
|
|
45
|
+
kwargs = {}
|
|
46
|
+
if api_key:
|
|
47
|
+
kwargs["api_key"] = api_key
|
|
48
|
+
if osrm_base_url:
|
|
49
|
+
kwargs["osrm_base_url"] = osrm_base_url
|
|
50
|
+
|
|
51
|
+
# Run TSP solver
|
|
52
|
+
result = shortest_path(input_file, method=method, distance=distance, **kwargs)
|
|
53
|
+
|
|
54
|
+
if ctx.obj.get("verbose"):
|
|
55
|
+
console.print(f"[green]TSP solved using {method}[/green]")
|
|
56
|
+
console.print(f"Total distance: {result.total_distance:.2f}")
|
|
57
|
+
console.print(f"Route length: {len(result.route)} points")
|
|
58
|
+
|
|
59
|
+
# Save results
|
|
60
|
+
if output:
|
|
61
|
+
DataHandler.save_results(result, output, format=output_format)
|
|
62
|
+
console.print(f"[green]Results saved to {output}[/green]")
|
|
63
|
+
else:
|
|
64
|
+
console.print(f"\nOptimal route: {result.route}")
|
|
65
|
+
console.print(f"Total distance: {result.total_distance:.2f}")
|
|
66
|
+
|
|
67
|
+
except NotImplementedError:
|
|
68
|
+
console.print(f"[yellow]{method} TSP solver will be implemented in Phase 5[/yellow]")
|
|
69
|
+
except Exception as e:
|
|
70
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
71
|
+
raise click.Abort() from e
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@click.command()
|
|
75
|
+
@click.argument("input_file", type=click.Path(exists=True))
|
|
76
|
+
@click.option(
|
|
77
|
+
"--distance",
|
|
78
|
+
"-d",
|
|
79
|
+
default="euclidean",
|
|
80
|
+
type=click.Choice(["euclidean", "haversine"]),
|
|
81
|
+
help="Distance metric to use",
|
|
82
|
+
)
|
|
83
|
+
@click.option("--output", "-o", type=click.Path(), help="Output file path")
|
|
84
|
+
@click.option(
|
|
85
|
+
"--format",
|
|
86
|
+
"output_format",
|
|
87
|
+
default="csv",
|
|
88
|
+
type=click.Choice(["csv", "json"]),
|
|
89
|
+
help="Output format",
|
|
90
|
+
)
|
|
91
|
+
@click.pass_context
|
|
92
|
+
def christofides(ctx, input_file, distance, output, output_format):
|
|
93
|
+
"""Solve TSP using Christofides algorithm (approximate)."""
|
|
94
|
+
from ..api import tsp_christofides
|
|
95
|
+
from ..io.data_handler import DataHandler
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
result = tsp_christofides(input_file, distance=distance)
|
|
99
|
+
|
|
100
|
+
if ctx.obj.get("verbose"):
|
|
101
|
+
console.print("[green]TSP solved using Christofides algorithm[/green]")
|
|
102
|
+
console.print(f"Total distance: {result.total_distance:.2f}")
|
|
103
|
+
|
|
104
|
+
if output:
|
|
105
|
+
DataHandler.save_results(result, output, format=output_format)
|
|
106
|
+
console.print(f"[green]Results saved to {output}[/green]")
|
|
107
|
+
else:
|
|
108
|
+
console.print(f"\nOptimal route: {result.route}")
|
|
109
|
+
console.print(f"Total distance: {result.total_distance:.2f}")
|
|
110
|
+
|
|
111
|
+
except NotImplementedError:
|
|
112
|
+
console.print("[yellow]Christofides TSP will be implemented in Phase 5[/yellow]")
|
|
113
|
+
except Exception as e:
|
|
114
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
115
|
+
raise click.Abort() from e
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@click.command()
|
|
119
|
+
@click.argument("input_file", type=click.Path(exists=True))
|
|
120
|
+
@click.option(
|
|
121
|
+
"--distance",
|
|
122
|
+
"-d",
|
|
123
|
+
default="euclidean",
|
|
124
|
+
type=click.Choice(["euclidean", "haversine", "osrm"]),
|
|
125
|
+
help="Distance metric to use",
|
|
126
|
+
)
|
|
127
|
+
@click.option("--output", "-o", type=click.Path(), help="Output file path")
|
|
128
|
+
@click.option(
|
|
129
|
+
"--format",
|
|
130
|
+
"output_format",
|
|
131
|
+
default="csv",
|
|
132
|
+
type=click.Choice(["csv", "json"]),
|
|
133
|
+
help="Output format",
|
|
134
|
+
)
|
|
135
|
+
@click.option("--osrm-base-url", help="Custom OSRM server URL")
|
|
136
|
+
@click.pass_context
|
|
137
|
+
def ortools(ctx, input_file, distance, output, output_format, osrm_base_url):
|
|
138
|
+
"""Solve TSP using Google OR-Tools (exact for small problems)."""
|
|
139
|
+
from ..api import tsp_ortools
|
|
140
|
+
from ..io.data_handler import DataHandler
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
kwargs = {}
|
|
144
|
+
if osrm_base_url:
|
|
145
|
+
kwargs["osrm_base_url"] = osrm_base_url
|
|
146
|
+
|
|
147
|
+
result = tsp_ortools(input_file, distance=distance, **kwargs)
|
|
148
|
+
|
|
149
|
+
if ctx.obj.get("verbose"):
|
|
150
|
+
console.print("[green]TSP solved using OR-Tools[/green]")
|
|
151
|
+
console.print(f"Total distance: {result.total_distance:.2f}")
|
|
152
|
+
|
|
153
|
+
if output:
|
|
154
|
+
DataHandler.save_results(result, output, format=output_format)
|
|
155
|
+
console.print(f"[green]Results saved to {output}[/green]")
|
|
156
|
+
else:
|
|
157
|
+
console.print(f"\nOptimal route: {result.route}")
|
|
158
|
+
console.print(f"Total distance: {result.total_distance:.2f}")
|
|
159
|
+
|
|
160
|
+
except NotImplementedError:
|
|
161
|
+
console.print("[yellow]OR-Tools TSP will be implemented in Phase 5[/yellow]")
|
|
162
|
+
except Exception as e:
|
|
163
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
164
|
+
raise click.Abort() from e
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core algorithms for clustering and optimization."""
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pure algorithm implementations without CLI, plotting, or file I/O.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import networkx as nx
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from ..distances import get_distance_matrix
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def initialize_centroids(points: np.ndarray, k: int, random_state: int | None = None) -> np.ndarray:
|
|
15
|
+
"""
|
|
16
|
+
Initialize k centroids by randomly selecting from points.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
points: Input points array with shape [n, 2]
|
|
20
|
+
k: Number of centroids
|
|
21
|
+
random_state: Random seed for reproducibility
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Array of k initial centroids
|
|
25
|
+
"""
|
|
26
|
+
if random_state is not None:
|
|
27
|
+
rng = np.random.RandomState(random_state)
|
|
28
|
+
rng_state = rng.get_state()
|
|
29
|
+
np.random.set_state(rng_state)
|
|
30
|
+
|
|
31
|
+
centroids = points.copy()
|
|
32
|
+
np.random.shuffle(centroids)
|
|
33
|
+
return centroids[:k]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def move_centroids(points: np.ndarray, closest: np.ndarray, centroids: np.ndarray) -> np.ndarray:
|
|
37
|
+
"""
|
|
38
|
+
Update centroids to the mean of their assigned points.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
points: All data points
|
|
42
|
+
closest: Array indicating which centroid each point belongs to
|
|
43
|
+
centroids: Current centroids
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Updated centroids
|
|
47
|
+
"""
|
|
48
|
+
new_centroids = [points[closest == k].mean(axis=0) for k in range(centroids.shape[0])]
|
|
49
|
+
|
|
50
|
+
# Handle empty clusters by keeping old centroid
|
|
51
|
+
for i, c in enumerate(new_centroids):
|
|
52
|
+
if np.isnan(c).any():
|
|
53
|
+
new_centroids[i] = centroids[i]
|
|
54
|
+
|
|
55
|
+
return np.array(new_centroids)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def kmeans_cluster(
|
|
59
|
+
data: pd.DataFrame | np.ndarray,
|
|
60
|
+
n_clusters: int,
|
|
61
|
+
distance_method: str = "euclidean",
|
|
62
|
+
max_iter: int = 300,
|
|
63
|
+
random_state: int | None = None,
|
|
64
|
+
**distance_kwargs,
|
|
65
|
+
) -> dict:
|
|
66
|
+
"""
|
|
67
|
+
Pure K-means clustering implementation.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
data: Input data as DataFrame with longitude/latitude or numpy array [n, 2]
|
|
71
|
+
n_clusters: Number of clusters
|
|
72
|
+
distance_method: Distance calculation method
|
|
73
|
+
max_iter: Maximum iterations
|
|
74
|
+
random_state: Random seed
|
|
75
|
+
**distance_kwargs: Additional arguments for distance calculation
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Dictionary with 'labels', 'centroids', 'iterations', 'converged'
|
|
79
|
+
"""
|
|
80
|
+
# Convert DataFrame to numpy array if needed
|
|
81
|
+
if isinstance(data, pd.DataFrame):
|
|
82
|
+
if "longitude" in data.columns and "latitude" in data.columns:
|
|
83
|
+
X = data[["longitude", "latitude"]].values
|
|
84
|
+
else:
|
|
85
|
+
raise ValueError("DataFrame must contain 'longitude' and 'latitude' columns")
|
|
86
|
+
else:
|
|
87
|
+
X = np.asarray(data)
|
|
88
|
+
|
|
89
|
+
# Initialize centroids
|
|
90
|
+
centroids = initialize_centroids(X, n_clusters, random_state)
|
|
91
|
+
old_centroids = centroids.copy()
|
|
92
|
+
|
|
93
|
+
for i in range(max_iter):
|
|
94
|
+
# Calculate distances and assign points to closest centroids
|
|
95
|
+
distances = get_distance_matrix(X, centroids, method=distance_method, **distance_kwargs)
|
|
96
|
+
labels = np.argmin(distances, axis=1)
|
|
97
|
+
|
|
98
|
+
# Update centroids
|
|
99
|
+
centroids = move_centroids(X, labels, centroids)
|
|
100
|
+
|
|
101
|
+
# Check for convergence
|
|
102
|
+
if np.allclose(old_centroids, centroids, rtol=1e-4):
|
|
103
|
+
return {
|
|
104
|
+
"labels": labels,
|
|
105
|
+
"centroids": centroids,
|
|
106
|
+
"iterations": i + 1,
|
|
107
|
+
"converged": True,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
old_centroids = centroids.copy()
|
|
111
|
+
|
|
112
|
+
return {"labels": labels, "centroids": centroids, "iterations": max_iter, "converged": False}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def sort_by_distance_assignment(
|
|
116
|
+
data: pd.DataFrame | np.ndarray,
|
|
117
|
+
centroids: np.ndarray,
|
|
118
|
+
distance_method: str = "euclidean",
|
|
119
|
+
**distance_kwargs,
|
|
120
|
+
) -> np.ndarray:
|
|
121
|
+
"""
|
|
122
|
+
Assign points to closest centroids (used by sort_by_distance).
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
data: Input data as DataFrame or numpy array
|
|
126
|
+
centroids: Centroid locations
|
|
127
|
+
distance_method: Distance calculation method
|
|
128
|
+
**distance_kwargs: Additional arguments for distance calculation
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Array of cluster assignments
|
|
132
|
+
"""
|
|
133
|
+
# Convert DataFrame to numpy array if needed
|
|
134
|
+
if isinstance(data, pd.DataFrame):
|
|
135
|
+
if "longitude" in data.columns and "latitude" in data.columns:
|
|
136
|
+
X = data[["longitude", "latitude"]].values
|
|
137
|
+
else:
|
|
138
|
+
raise ValueError("DataFrame must contain 'longitude' and 'latitude' columns")
|
|
139
|
+
else:
|
|
140
|
+
X = np.asarray(data)
|
|
141
|
+
|
|
142
|
+
# Calculate distances and assign to closest
|
|
143
|
+
distances = get_distance_matrix(X, centroids, method=distance_method, **distance_kwargs)
|
|
144
|
+
labels = np.argmin(distances, axis=1)
|
|
145
|
+
|
|
146
|
+
return labels
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def calculate_cluster_statistics(
|
|
150
|
+
data: pd.DataFrame, labels: np.ndarray, distance_method: str = "euclidean", **distance_kwargs
|
|
151
|
+
) -> list[dict]:
|
|
152
|
+
"""
|
|
153
|
+
Calculate statistics for each cluster (used by comparison functions).
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
data: Input data with coordinates
|
|
157
|
+
labels: Cluster assignments
|
|
158
|
+
distance_method: Distance calculation method
|
|
159
|
+
**distance_kwargs: Additional arguments for distance calculation
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
List of dictionaries with cluster statistics
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
results = []
|
|
166
|
+
X = data[["longitude", "latitude"]].values
|
|
167
|
+
|
|
168
|
+
for cluster_id in sorted(np.unique(labels)):
|
|
169
|
+
cluster_points = X[labels == cluster_id]
|
|
170
|
+
n_points = len(cluster_points)
|
|
171
|
+
|
|
172
|
+
if n_points <= 1:
|
|
173
|
+
# Skip clusters with 0 or 1 points
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
# Calculate distance matrix for this cluster
|
|
177
|
+
distances = get_distance_matrix(
|
|
178
|
+
cluster_points, cluster_points, method=distance_method, **distance_kwargs
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if distances is None:
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
# Create graph and calculate MST
|
|
185
|
+
G = nx.from_numpy_matrix(distances)
|
|
186
|
+
T = nx.minimum_spanning_tree(G)
|
|
187
|
+
|
|
188
|
+
graph_weight = int(G.size(weight="weight") / 1000)
|
|
189
|
+
mst_weight = int(T.size(weight="weight") / 1000)
|
|
190
|
+
|
|
191
|
+
results.append(
|
|
192
|
+
{
|
|
193
|
+
"label": cluster_id,
|
|
194
|
+
"n": n_points,
|
|
195
|
+
"graph_weight": graph_weight,
|
|
196
|
+
"mst_weight": mst_weight,
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return results
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pure routing/TSP algorithm implementations.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import googlemaps
|
|
8
|
+
import networkx as nx
|
|
9
|
+
import numpy as np
|
|
10
|
+
import requests
|
|
11
|
+
from ortools.constraint_solver import pywrapcp, routing_enums_pb2
|
|
12
|
+
|
|
13
|
+
from ..distances import get_distance_matrix
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def solve_tsp_ortools(
|
|
17
|
+
points: np.ndarray, distance_method: str = "euclidean", **distance_kwargs
|
|
18
|
+
) -> tuple[float, list[int]]:
|
|
19
|
+
"""
|
|
20
|
+
Solve TSP using Google OR-Tools.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
points: Array of [lon, lat] coordinates
|
|
24
|
+
distance_method: Distance calculation method
|
|
25
|
+
**distance_kwargs: Additional args for distance calculation
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
(total_distance, route) tuple
|
|
29
|
+
"""
|
|
30
|
+
# Check for empty data
|
|
31
|
+
if len(points) == 0:
|
|
32
|
+
raise ValueError("Cannot solve TSP with empty data")
|
|
33
|
+
|
|
34
|
+
# Check for single point
|
|
35
|
+
if len(points) == 1:
|
|
36
|
+
return 0.0, [0, 0] # Visit same point twice
|
|
37
|
+
|
|
38
|
+
# Get distance matrix
|
|
39
|
+
distances = get_distance_matrix(points, points, method=distance_method, **distance_kwargs)
|
|
40
|
+
|
|
41
|
+
if distances is None:
|
|
42
|
+
raise ValueError("Could not calculate distance matrix")
|
|
43
|
+
|
|
44
|
+
# Create routing model with index manager
|
|
45
|
+
tsp_size = len(points)
|
|
46
|
+
num_vehicles = 1
|
|
47
|
+
depot = 0
|
|
48
|
+
|
|
49
|
+
# Create the routing index manager
|
|
50
|
+
manager = pywrapcp.RoutingIndexManager(tsp_size, num_vehicles, depot)
|
|
51
|
+
|
|
52
|
+
# Create routing model
|
|
53
|
+
routing = pywrapcp.RoutingModel(manager)
|
|
54
|
+
|
|
55
|
+
# Set up distance callback
|
|
56
|
+
def distance_callback(from_index, to_index):
|
|
57
|
+
# Convert from routing variable Index to distance matrix NodeIndex
|
|
58
|
+
from_node = manager.IndexToNode(from_index)
|
|
59
|
+
to_node = manager.IndexToNode(to_index)
|
|
60
|
+
return int(distances[from_node, to_node])
|
|
61
|
+
|
|
62
|
+
transit_callback_index = routing.RegisterTransitCallback(distance_callback)
|
|
63
|
+
routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index)
|
|
64
|
+
|
|
65
|
+
# Set search parameters
|
|
66
|
+
search_params = pywrapcp.DefaultRoutingSearchParameters()
|
|
67
|
+
search_params.first_solution_strategy = (
|
|
68
|
+
routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Solve
|
|
72
|
+
assignment = routing.SolveWithParameters(search_params)
|
|
73
|
+
|
|
74
|
+
if not assignment:
|
|
75
|
+
return float("inf"), []
|
|
76
|
+
|
|
77
|
+
# Extract tour
|
|
78
|
+
tour = []
|
|
79
|
+
index = routing.Start(0)
|
|
80
|
+
while not routing.IsEnd(index):
|
|
81
|
+
tour.append(manager.IndexToNode(index))
|
|
82
|
+
index = assignment.Value(routing.NextVar(index))
|
|
83
|
+
tour.append(manager.IndexToNode(index)) # Return to start (depot)
|
|
84
|
+
|
|
85
|
+
total_distance = assignment.ObjectiveValue()
|
|
86
|
+
|
|
87
|
+
return float(total_distance), tour
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def solve_tsp_christofides(
|
|
91
|
+
points: np.ndarray, distance_method: str = "euclidean", **distance_kwargs
|
|
92
|
+
) -> tuple[float, list[int]]:
|
|
93
|
+
"""
|
|
94
|
+
Solve TSP using Christofides algorithm (approximate).
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
points: Array of [lon, lat] coordinates
|
|
98
|
+
distance_method: Distance calculation method
|
|
99
|
+
**distance_kwargs: Additional args for distance calculation
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
(total_distance, route) tuple
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
from Christofides import christofides
|
|
106
|
+
except ImportError as e:
|
|
107
|
+
raise ImportError(
|
|
108
|
+
"Christofides algorithm requires the 'Christofides' package. "
|
|
109
|
+
"Install it with: pip install Christofides"
|
|
110
|
+
) from e
|
|
111
|
+
|
|
112
|
+
# Get distance matrix
|
|
113
|
+
distances = get_distance_matrix(points, points, method=distance_method, **distance_kwargs)
|
|
114
|
+
|
|
115
|
+
if distances is None:
|
|
116
|
+
raise ValueError("Could not calculate distance matrix")
|
|
117
|
+
|
|
118
|
+
# Create complete graph from distance matrix
|
|
119
|
+
G = nx.Graph()
|
|
120
|
+
n = len(points)
|
|
121
|
+
|
|
122
|
+
for i in range(n):
|
|
123
|
+
for j in range(i + 1, n):
|
|
124
|
+
G.add_edge(i, j, weight=distances[i, j])
|
|
125
|
+
|
|
126
|
+
# Solve using Christofides algorithm
|
|
127
|
+
tour = christofides(G, 0) # Start from node 0
|
|
128
|
+
|
|
129
|
+
# Calculate total distance
|
|
130
|
+
total_distance = 0.0
|
|
131
|
+
for i in range(len(tour) - 1):
|
|
132
|
+
total_distance += distances[tour[i], tour[i + 1]]
|
|
133
|
+
|
|
134
|
+
return total_distance, tour
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def solve_tsp_osrm(
|
|
138
|
+
points: np.ndarray, osrm_base_url: str | None = None, **kwargs
|
|
139
|
+
) -> tuple[float, list[int]]:
|
|
140
|
+
"""
|
|
141
|
+
Solve TSP using OSRM trip service.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
points: Array of [lon, lat] coordinates
|
|
145
|
+
osrm_base_url: Custom OSRM server URL
|
|
146
|
+
**kwargs: Additional arguments
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
(total_distance, route) tuple
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
if osrm_base_url is None:
|
|
153
|
+
osrm_base_url = "http://router.project-osrm.org"
|
|
154
|
+
|
|
155
|
+
# Format coordinates for OSRM (lon,lat format)
|
|
156
|
+
coords = ";".join([f"{point[0]},{point[1]}" for point in points])
|
|
157
|
+
|
|
158
|
+
# Build OSRM trip URL
|
|
159
|
+
url = f"{osrm_base_url}/trip/v1/driving/{coords}"
|
|
160
|
+
params = {
|
|
161
|
+
"source": "first",
|
|
162
|
+
"destination": "last",
|
|
163
|
+
"roundtrip": "true",
|
|
164
|
+
"steps": "false",
|
|
165
|
+
"geometries": "polyline",
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
response = requests.get(url, params=params, timeout=30)
|
|
170
|
+
response.raise_for_status()
|
|
171
|
+
data = response.json()
|
|
172
|
+
|
|
173
|
+
if data["code"] != "Ok":
|
|
174
|
+
raise ValueError(f"OSRM error: {data.get('message', 'Unknown error')}")
|
|
175
|
+
|
|
176
|
+
trip = data["trips"][0]
|
|
177
|
+
total_distance = trip["distance"] # meters
|
|
178
|
+
|
|
179
|
+
# Extract waypoint order
|
|
180
|
+
waypoints = trip["legs"]
|
|
181
|
+
route = [0] # Start with first point
|
|
182
|
+
|
|
183
|
+
# OSRM returns the optimal order
|
|
184
|
+
for _leg in waypoints:
|
|
185
|
+
# This is simplified - real implementation would need to parse the leg structure
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
# For now, return a simple route (this would need proper OSRM response parsing)
|
|
189
|
+
route = [*list(range(len(points))), 0] # Simple circular tour
|
|
190
|
+
|
|
191
|
+
return total_distance, route
|
|
192
|
+
|
|
193
|
+
except requests.RequestException as e:
|
|
194
|
+
raise ValueError(f"OSRM request failed: {e}") from e
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def solve_tsp_google(points: np.ndarray, api_key: str, **kwargs) -> tuple[float, list[int]]:
|
|
198
|
+
"""
|
|
199
|
+
Solve TSP using Google Maps Directions API.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
points: Array of [lon, lat] coordinates
|
|
203
|
+
api_key: Google Maps API key
|
|
204
|
+
**kwargs: Additional arguments
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
(total_distance, route) tuple
|
|
208
|
+
"""
|
|
209
|
+
gmaps = googlemaps.Client(key=api_key)
|
|
210
|
+
|
|
211
|
+
# Convert points to lat,lng format for Google
|
|
212
|
+
locations = [(point[1], point[0]) for point in points] # Google uses lat,lng
|
|
213
|
+
|
|
214
|
+
# This is a simplified implementation
|
|
215
|
+
# A full implementation would use the Google Directions API with waypoints
|
|
216
|
+
# and potentially solve the TSP optimization externally
|
|
217
|
+
|
|
218
|
+
# For now, return a basic circular tour
|
|
219
|
+
n = len(points)
|
|
220
|
+
route = [*list(range(n)), 0]
|
|
221
|
+
|
|
222
|
+
# Calculate approximate distance using Google Distance Matrix
|
|
223
|
+
try:
|
|
224
|
+
matrix = gmaps.distance_matrix(
|
|
225
|
+
origins=locations, destinations=locations, mode="driving", units="metric"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
total_distance = 0
|
|
229
|
+
for i in range(len(route) - 1):
|
|
230
|
+
from_idx = route[i]
|
|
231
|
+
to_idx = route[i + 1]
|
|
232
|
+
distance_info = matrix["rows"][from_idx]["elements"][to_idx]
|
|
233
|
+
|
|
234
|
+
if distance_info["status"] == "OK":
|
|
235
|
+
total_distance += distance_info["distance"]["value"] # meters
|
|
236
|
+
else:
|
|
237
|
+
raise ValueError(f"Google Maps error: {distance_info['status']}")
|
|
238
|
+
|
|
239
|
+
return float(total_distance), route
|
|
240
|
+
|
|
241
|
+
except Exception as e:
|
|
242
|
+
raise ValueError(f"Google Maps API error: {e}") from e
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Distance matrix calculations."""
|
|
2
|
+
|
|
3
|
+
from .euclidean import euclidean_distance_matrix, latlon2xy, pairwise_distances, xy2latlog
|
|
4
|
+
from .external_apis import google_distance_matrix, osrm_distance_matrix
|
|
5
|
+
from .factory import get_distance_matrix
|
|
6
|
+
from .haversine import haversine_distance_matrix
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"euclidean_distance_matrix",
|
|
10
|
+
"get_distance_matrix",
|
|
11
|
+
"google_distance_matrix",
|
|
12
|
+
"haversine_distance_matrix",
|
|
13
|
+
"latlon2xy",
|
|
14
|
+
"osrm_distance_matrix",
|
|
15
|
+
"pairwise_distances",
|
|
16
|
+
"xy2latlog",
|
|
17
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Euclidean distance calculations for geographic coordinates.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import utm
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def pairwise_distances(X: np.ndarray, Y: np.ndarray | None = None) -> np.ndarray:
|
|
12
|
+
"""Pairwise euclidean distance calculation."""
|
|
13
|
+
if Y is None:
|
|
14
|
+
Y = X
|
|
15
|
+
return np.sqrt(((Y - X[:, np.newaxis]) ** 2).sum(axis=2))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def latlon2xy(lat: float, lon: float) -> list[float]:
|
|
19
|
+
"""Transform lat/lon to UTM coordinate
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
lat (float): WGS latitude
|
|
23
|
+
lon (float): WGS longitude
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
[x, y]: UTM x, y coordinate
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
utm_x, utm_y, _, _ = utm.from_latlon(lat, lon)
|
|
30
|
+
|
|
31
|
+
return [utm_x, utm_y]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def xy2latlog(
|
|
35
|
+
x: float, y: float, zone_number: int, zone_letter: str | None = None
|
|
36
|
+
) -> tuple[float, float]:
|
|
37
|
+
"""Transform x, y coordinate to lat/lon coordinate
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
x (float): UTM x coordinate
|
|
41
|
+
y (float): UTM y coordinate
|
|
42
|
+
zone_number (int): UTM zone number
|
|
43
|
+
zone_letter (str, optional): UTM zone letter. Defaults to None.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
(lat, lon): WGS latitude, longitude
|
|
47
|
+
|
|
48
|
+
"""
|
|
49
|
+
lat, lon = utm.to_latlon(x, y, zone_number, zone_letter)
|
|
50
|
+
|
|
51
|
+
return (lat, lon)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def euclidean_distance_matrix(
|
|
55
|
+
points_from: np.ndarray, points_to: np.ndarray | None = None
|
|
56
|
+
) -> np.ndarray:
|
|
57
|
+
"""
|
|
58
|
+
Calculate euclidean distance matrix between two sets of lat/lon points.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
points_from: Source points (numpy array with shape [n, 2] where columns are [lon, lat])
|
|
62
|
+
points_to: Destination points (optional, defaults to points_from)
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Distance matrix as numpy array with shape [len(points_from), len(points_to)]
|
|
66
|
+
"""
|
|
67
|
+
# Handle empty arrays
|
|
68
|
+
if len(points_from) == 0:
|
|
69
|
+
points_to_len = len(points_to) if points_to is not None else 0
|
|
70
|
+
return np.array([]).reshape(0, points_to_len)
|
|
71
|
+
|
|
72
|
+
# Convert lat/lon to UTM coordinates for accurate euclidean distance
|
|
73
|
+
utm_from = np.array([latlon2xy(lat, lon) for lon, lat in points_from])
|
|
74
|
+
|
|
75
|
+
if points_to is None:
|
|
76
|
+
utm_to = utm_from
|
|
77
|
+
else:
|
|
78
|
+
utm_to = np.array([latlon2xy(lat, lon) for lon, lat in points_to])
|
|
79
|
+
|
|
80
|
+
return pairwise_distances(utm_from, utm_to)
|