allocator 1.0.0__tar.gz → 1.1.0__tar.gz
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-1.0.0 → allocator-1.1.0}/PKG-INFO +2 -3
- allocator-1.1.0/allocator/core/__init__.py +15 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/core/algorithms.py +159 -10
- {allocator-1.0.0 → allocator-1.1.0}/allocator/viz/__init__.py +4 -0
- allocator-1.1.0/allocator/viz/plotting.py +546 -0
- {allocator-1.0.0 → allocator-1.1.0}/pyproject.toml +15 -5
- allocator-1.0.0/allocator/core/__init__.py +0 -1
- allocator-1.0.0/allocator/viz/plotting.py +0 -206
- {allocator-1.0.0 → allocator-1.1.0}/README.md +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/__init__.py +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/api/__init__.py +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/api/cluster.py +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/api/distance.py +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/api/route.py +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/api/types.py +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/cli/__init__.py +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/cli/cluster_cmd.py +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/cli/main.py +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/cli/route_cmd.py +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/core/routing.py +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/distances/__init__.py +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/distances/euclidean.py +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/distances/external_apis.py +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/distances/factory.py +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/distances/haversine.py +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/io/__init__.py +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/io/data_handler.py +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/py.typed +0 -0
- {allocator-1.0.0 → allocator-1.1.0}/allocator/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: allocator
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Modern Python package for geographic task allocation, clustering, and routing optimization
|
|
5
5
|
Keywords: geographic,allocation,clustering,routing,optimization,tsp,kmeans,geospatial,logistics,shortest-path
|
|
6
6
|
Author: Suriyan Laohaprapanon, Gaurav Sood
|
|
@@ -22,7 +22,6 @@ Classifier: Topic :: Utilities
|
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Dist: pandas>=2.0.0
|
|
24
24
|
Requires-Dist: numpy>=1.24.0
|
|
25
|
-
Requires-Dist: scikit-learn>=1.3.0
|
|
26
25
|
Requires-Dist: utm>=0.7.0
|
|
27
26
|
Requires-Dist: haversine>=2.8.0
|
|
28
27
|
Requires-Dist: networkx>=3.0
|
|
@@ -33,8 +32,8 @@ Requires-Dist: googlemaps>=4.6.0
|
|
|
33
32
|
Requires-Dist: ortools>=9.5.0
|
|
34
33
|
Requires-Dist: matplotlib>=3.6.0
|
|
35
34
|
Requires-Dist: seaborn>=0.13.2
|
|
36
|
-
Requires-Dist: scipy>=1.10.0 ; extra == 'algorithms'
|
|
37
35
|
Requires-Dist: christofides>=1.0.0 ; extra == 'algorithms'
|
|
36
|
+
Requires-Dist: scikit-learn>=1.3.0 ; extra == 'algorithms'
|
|
38
37
|
Requires-Dist: allocator[algorithms,geo] ; extra == 'all'
|
|
39
38
|
Requires-Dist: allocator[all,dev,test,docs] ; extra == 'complete'
|
|
40
39
|
Requires-Dist: ruff>=0.1.0 ; extra == 'dev'
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Core algorithms for clustering and optimization."""
|
|
2
|
+
|
|
3
|
+
from .algorithms import (
|
|
4
|
+
CustomKMeans,
|
|
5
|
+
calculate_cluster_statistics,
|
|
6
|
+
kmeans_cluster,
|
|
7
|
+
sort_by_distance_assignment,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"CustomKMeans",
|
|
12
|
+
"calculate_cluster_statistics",
|
|
13
|
+
"kmeans_cluster",
|
|
14
|
+
"sort_by_distance_assignment",
|
|
15
|
+
]
|
|
@@ -10,6 +10,14 @@ import pandas as pd
|
|
|
10
10
|
|
|
11
11
|
from ..distances import get_distance_matrix
|
|
12
12
|
|
|
13
|
+
try:
|
|
14
|
+
from sklearn.cluster import KMeans
|
|
15
|
+
from sklearn.utils.validation import check_array
|
|
16
|
+
|
|
17
|
+
HAS_SKLEARN = True
|
|
18
|
+
except ImportError:
|
|
19
|
+
HAS_SKLEARN = False
|
|
20
|
+
|
|
13
21
|
|
|
14
22
|
def initialize_centroids(points: np.ndarray, k: int, random_state: int | None = None) -> np.ndarray:
|
|
15
23
|
"""
|
|
@@ -55,6 +63,107 @@ def move_centroids(points: np.ndarray, closest: np.ndarray, centroids: np.ndarra
|
|
|
55
63
|
return np.array(new_centroids)
|
|
56
64
|
|
|
57
65
|
|
|
66
|
+
class CustomKMeans(KMeans if HAS_SKLEARN else object):
|
|
67
|
+
"""
|
|
68
|
+
Custom K-means implementation that supports geographic distance metrics.
|
|
69
|
+
|
|
70
|
+
This class extends sklearn's KMeans to work with custom distance functions
|
|
71
|
+
including haversine, OSRM, and Google Maps API distances.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
n_clusters=8,
|
|
77
|
+
distance_method="euclidean",
|
|
78
|
+
max_iter=300,
|
|
79
|
+
random_state=None,
|
|
80
|
+
**distance_kwargs,
|
|
81
|
+
):
|
|
82
|
+
if HAS_SKLEARN:
|
|
83
|
+
# Initialize sklearn KMeans with all parameters
|
|
84
|
+
super().__init__(n_clusters=n_clusters, max_iter=max_iter, random_state=random_state)
|
|
85
|
+
self.distance_method = distance_method
|
|
86
|
+
self.distance_kwargs = distance_kwargs
|
|
87
|
+
self.n_clusters = n_clusters
|
|
88
|
+
|
|
89
|
+
def _transform(self, X):
|
|
90
|
+
"""Override sklearn's distance calculation to use custom metrics."""
|
|
91
|
+
if not HAS_SKLEARN:
|
|
92
|
+
raise ImportError(
|
|
93
|
+
"sklearn is required for CustomKMeans. Install with: pip install 'allocator[algorithms]'"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Use our custom distance factory instead of sklearn's euclidean
|
|
97
|
+
distances = get_distance_matrix(
|
|
98
|
+
X, self.cluster_centers_, method=self.distance_method, **self.distance_kwargs
|
|
99
|
+
)
|
|
100
|
+
return distances
|
|
101
|
+
|
|
102
|
+
def _update_centroids(self, X, labels):
|
|
103
|
+
"""Update centroids using geographic mean for custom distances."""
|
|
104
|
+
new_centroids = []
|
|
105
|
+
for k in range(self.n_clusters):
|
|
106
|
+
mask = labels == k
|
|
107
|
+
if np.any(mask):
|
|
108
|
+
# For geographic data, use simple mean of coordinates
|
|
109
|
+
# This works well for most geographic clustering tasks
|
|
110
|
+
cluster_points = X[mask]
|
|
111
|
+
centroid = np.mean(cluster_points, axis=0)
|
|
112
|
+
new_centroids.append(centroid)
|
|
113
|
+
else:
|
|
114
|
+
# Keep old centroid if cluster is empty
|
|
115
|
+
new_centroids.append(self.cluster_centers_[k])
|
|
116
|
+
return np.array(new_centroids)
|
|
117
|
+
|
|
118
|
+
def fit(self, X, y=None, sample_weight=None):
|
|
119
|
+
"""Fit the k-means clustering with custom distance metric."""
|
|
120
|
+
if not HAS_SKLEARN:
|
|
121
|
+
# Fallback to original implementation if sklearn not available
|
|
122
|
+
return self._fit_custom_implementation(X)
|
|
123
|
+
|
|
124
|
+
X = check_array(X, accept_sparse="csr", dtype=[np.float64, np.float32])
|
|
125
|
+
|
|
126
|
+
# Initialize using sklearn's initialization logic
|
|
127
|
+
super().fit(X)
|
|
128
|
+
|
|
129
|
+
# Now run our custom iterations
|
|
130
|
+
for iteration in range(self.max_iter):
|
|
131
|
+
# Calculate distances using custom metric
|
|
132
|
+
distances = get_distance_matrix(
|
|
133
|
+
X, self.cluster_centers_, method=self.distance_method, **self.distance_kwargs
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Assign points to nearest centroids
|
|
137
|
+
labels = np.argmin(distances, axis=1)
|
|
138
|
+
|
|
139
|
+
# Update centroids
|
|
140
|
+
new_centroids = self._update_centroids(X, labels)
|
|
141
|
+
|
|
142
|
+
# Check convergence
|
|
143
|
+
if np.allclose(self.cluster_centers_, new_centroids, rtol=1e-4):
|
|
144
|
+
self.cluster_centers_ = new_centroids
|
|
145
|
+
self.labels_ = labels
|
|
146
|
+
self.n_iter_ = iteration + 1
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
self.cluster_centers_ = new_centroids
|
|
150
|
+
else:
|
|
151
|
+
self.labels_ = labels
|
|
152
|
+
self.n_iter_ = self.max_iter
|
|
153
|
+
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
def _fit_custom_implementation(self, X):
|
|
157
|
+
"""Fallback to original implementation when sklearn is not available."""
|
|
158
|
+
result = _kmeans_cluster_original(
|
|
159
|
+
X, self.n_clusters, distance_method=self.distance_method, **self.distance_kwargs
|
|
160
|
+
)
|
|
161
|
+
self.cluster_centers_ = result["centroids"]
|
|
162
|
+
self.labels_ = result["labels"]
|
|
163
|
+
self.n_iter_ = result["iterations"]
|
|
164
|
+
return self
|
|
165
|
+
|
|
166
|
+
|
|
58
167
|
def kmeans_cluster(
|
|
59
168
|
data: pd.DataFrame | np.ndarray,
|
|
60
169
|
n_clusters: int,
|
|
@@ -64,10 +173,57 @@ def kmeans_cluster(
|
|
|
64
173
|
**distance_kwargs,
|
|
65
174
|
) -> dict:
|
|
66
175
|
"""
|
|
67
|
-
|
|
176
|
+
K-means clustering with support for custom distance metrics.
|
|
177
|
+
|
|
178
|
+
This function provides a unified interface that uses sklearn when available
|
|
179
|
+
and falls back to the original implementation otherwise.
|
|
180
|
+
"""
|
|
181
|
+
# Convert DataFrame to numpy array if needed
|
|
182
|
+
if isinstance(data, pd.DataFrame):
|
|
183
|
+
if "longitude" in data.columns and "latitude" in data.columns:
|
|
184
|
+
X = data[["longitude", "latitude"]].values
|
|
185
|
+
else:
|
|
186
|
+
raise ValueError("DataFrame must contain 'longitude' and 'latitude' columns")
|
|
187
|
+
else:
|
|
188
|
+
X = np.asarray(data)
|
|
189
|
+
|
|
190
|
+
# Use sklearn-based implementation if available
|
|
191
|
+
if HAS_SKLEARN and distance_method in ["euclidean", "haversine", "osrm", "google"]:
|
|
192
|
+
kmeans = CustomKMeans(
|
|
193
|
+
n_clusters=n_clusters,
|
|
194
|
+
distance_method=distance_method,
|
|
195
|
+
max_iter=max_iter,
|
|
196
|
+
random_state=random_state,
|
|
197
|
+
**distance_kwargs,
|
|
198
|
+
)
|
|
199
|
+
kmeans.fit(X)
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
"labels": kmeans.labels_,
|
|
203
|
+
"centroids": kmeans.cluster_centers_,
|
|
204
|
+
"iterations": kmeans.n_iter_,
|
|
205
|
+
"converged": kmeans.n_iter_ < max_iter,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# Fall back to original implementation
|
|
209
|
+
return _kmeans_cluster_original(
|
|
210
|
+
X, n_clusters, distance_method, max_iter, random_state, **distance_kwargs
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _kmeans_cluster_original(
|
|
215
|
+
data: np.ndarray,
|
|
216
|
+
n_clusters: int,
|
|
217
|
+
distance_method: str = "euclidean",
|
|
218
|
+
max_iter: int = 300,
|
|
219
|
+
random_state: int | None = None,
|
|
220
|
+
**distance_kwargs,
|
|
221
|
+
) -> dict:
|
|
222
|
+
"""
|
|
223
|
+
Original pure K-means clustering implementation (fallback).
|
|
68
224
|
|
|
69
225
|
Args:
|
|
70
|
-
data: Input data as
|
|
226
|
+
data: Input data as numpy array [n, 2]
|
|
71
227
|
n_clusters: Number of clusters
|
|
72
228
|
distance_method: Distance calculation method
|
|
73
229
|
max_iter: Maximum iterations
|
|
@@ -77,14 +233,7 @@ def kmeans_cluster(
|
|
|
77
233
|
Returns:
|
|
78
234
|
Dictionary with 'labels', 'centroids', 'iterations', 'converged'
|
|
79
235
|
"""
|
|
80
|
-
|
|
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)
|
|
236
|
+
X = data
|
|
88
237
|
|
|
89
238
|
# Initialize centroids
|
|
90
239
|
centroids = initialize_centroids(X, n_clusters, random_state)
|
|
@@ -3,15 +3,19 @@
|
|
|
3
3
|
from .plotting import (
|
|
4
4
|
plot_assignments,
|
|
5
5
|
plot_clusters,
|
|
6
|
+
plot_clusters_interactive,
|
|
6
7
|
plot_clusters_on_axis,
|
|
7
8
|
plot_comparison,
|
|
8
9
|
plot_route,
|
|
10
|
+
plot_route_interactive,
|
|
9
11
|
)
|
|
10
12
|
|
|
11
13
|
__all__ = [
|
|
12
14
|
"plot_assignments",
|
|
13
15
|
"plot_clusters",
|
|
16
|
+
"plot_clusters_interactive",
|
|
14
17
|
"plot_clusters_on_axis",
|
|
15
18
|
"plot_comparison",
|
|
16
19
|
"plot_route",
|
|
20
|
+
"plot_route_interactive",
|
|
17
21
|
]
|
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Visualization utilities for allocator package.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from matplotlib import colors
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import folium
|
|
14
|
+
from folium import plugins
|
|
15
|
+
|
|
16
|
+
HAS_FOLIUM = True
|
|
17
|
+
except ImportError:
|
|
18
|
+
HAS_FOLIUM = False
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import polyline
|
|
22
|
+
|
|
23
|
+
HAS_POLYLINE = True
|
|
24
|
+
except ImportError:
|
|
25
|
+
HAS_POLYLINE = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def plot_clusters(
|
|
29
|
+
data: np.ndarray | pd.DataFrame,
|
|
30
|
+
labels: np.ndarray,
|
|
31
|
+
centroids: np.ndarray | None = None,
|
|
32
|
+
title: str = "Clustering Results",
|
|
33
|
+
save_path: str | None = None,
|
|
34
|
+
show: bool = True,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Plot clustering results.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
data: Data points as numpy array [n, 2] with [lon, lat] or DataFrame with longitude, latitude
|
|
41
|
+
labels: Cluster assignments for each point
|
|
42
|
+
centroids: Optional cluster centers as numpy array [k, 2]
|
|
43
|
+
title: Plot title
|
|
44
|
+
save_path: Path to save plot (optional)
|
|
45
|
+
show: Whether to display plot
|
|
46
|
+
"""
|
|
47
|
+
# Convert DataFrame to numpy array if needed
|
|
48
|
+
if isinstance(data, pd.DataFrame):
|
|
49
|
+
if "longitude" in data.columns and "latitude" in data.columns:
|
|
50
|
+
X = data[["longitude", "latitude"]].values
|
|
51
|
+
else:
|
|
52
|
+
raise ValueError("DataFrame must contain 'longitude' and 'latitude' columns")
|
|
53
|
+
else:
|
|
54
|
+
X = data
|
|
55
|
+
|
|
56
|
+
fig = plt.figure(figsize=(8, 8))
|
|
57
|
+
plt.ticklabel_format(useOffset=False)
|
|
58
|
+
cvalues = list(colors.cnames.values())
|
|
59
|
+
|
|
60
|
+
n_clusters = len(np.unique(labels))
|
|
61
|
+
|
|
62
|
+
ax = fig.add_subplot(1, 1, 1)
|
|
63
|
+
for k, col in zip(range(n_clusters), cvalues, strict=False):
|
|
64
|
+
my_members = labels == k
|
|
65
|
+
ax.plot(X[my_members, 0], X[my_members, 1], "w", markerfacecolor=col, marker=".")
|
|
66
|
+
|
|
67
|
+
if centroids is not None:
|
|
68
|
+
ax.plot(
|
|
69
|
+
centroids[k][0],
|
|
70
|
+
centroids[k][1],
|
|
71
|
+
"o",
|
|
72
|
+
markerfacecolor=col,
|
|
73
|
+
markeredgecolor="k",
|
|
74
|
+
markersize=6,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
ax.set_title(title)
|
|
78
|
+
# ax.set_xticks(())
|
|
79
|
+
# ax.set_yticks(())
|
|
80
|
+
|
|
81
|
+
if save_path:
|
|
82
|
+
fig.savefig(save_path)
|
|
83
|
+
|
|
84
|
+
if show:
|
|
85
|
+
plt.show()
|
|
86
|
+
|
|
87
|
+
plt.close()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def plot_assignments(
|
|
91
|
+
data: pd.DataFrame,
|
|
92
|
+
title: str = "Assignment Results",
|
|
93
|
+
save_path: str | None = None,
|
|
94
|
+
show: bool = True,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""
|
|
97
|
+
Plot assignment results for sort_by_distance type algorithms.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
data: DataFrame with longitude, latitude, and assigned_points columns
|
|
101
|
+
title: Plot title
|
|
102
|
+
save_path: Path to save plot (optional)
|
|
103
|
+
show: Whether to display plot
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
if "assigned_points" not in data.columns:
|
|
107
|
+
raise ValueError("DataFrame must contain 'assigned_points' column")
|
|
108
|
+
|
|
109
|
+
X = data[["longitude", "latitude"]].values
|
|
110
|
+
labels = data["assigned_points"].values - 1 # Convert to 0-based indexing
|
|
111
|
+
|
|
112
|
+
# Use the main cluster plotting function
|
|
113
|
+
plot_clusters(X, labels, centroids=None, title=title, save_path=save_path, show=show)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def plot_route(
|
|
117
|
+
route_points: np.ndarray,
|
|
118
|
+
route_order: list[int] | None = None,
|
|
119
|
+
title: str = "Route",
|
|
120
|
+
save_path: str | None = None,
|
|
121
|
+
show: bool = True,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""
|
|
124
|
+
Plot TSP/routing results.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
route_points: Points in the route as numpy array [n, 2] with [lon, lat]
|
|
128
|
+
route_order: Order to visit points (optional, defaults to sequential)
|
|
129
|
+
title: Plot title
|
|
130
|
+
save_path: Path to save plot (optional)
|
|
131
|
+
show: Whether to display plot
|
|
132
|
+
"""
|
|
133
|
+
if route_order is None:
|
|
134
|
+
route_order = list(range(len(route_points)))
|
|
135
|
+
|
|
136
|
+
fig = plt.figure(figsize=(10, 8))
|
|
137
|
+
ax = fig.add_subplot(1, 1, 1)
|
|
138
|
+
|
|
139
|
+
# Plot points
|
|
140
|
+
ax.scatter(route_points[:, 0], route_points[:, 1], c="red", s=50, zorder=5)
|
|
141
|
+
|
|
142
|
+
# Plot route
|
|
143
|
+
ordered_points = route_points[route_order]
|
|
144
|
+
# Add return to start
|
|
145
|
+
route_x = np.append(ordered_points[:, 0], ordered_points[0, 0])
|
|
146
|
+
route_y = np.append(ordered_points[:, 1], ordered_points[0, 1])
|
|
147
|
+
|
|
148
|
+
ax.plot(route_x, route_y, "b-", linewidth=2, alpha=0.7)
|
|
149
|
+
|
|
150
|
+
# Add point labels
|
|
151
|
+
for i, (x, y) in enumerate(route_points):
|
|
152
|
+
ax.annotate(str(i), (x, y), xytext=(5, 5), textcoords="offset points")
|
|
153
|
+
|
|
154
|
+
ax.set_title(title)
|
|
155
|
+
ax.set_xlabel("Longitude")
|
|
156
|
+
ax.set_ylabel("Latitude")
|
|
157
|
+
ax.grid(True, alpha=0.3)
|
|
158
|
+
|
|
159
|
+
if save_path:
|
|
160
|
+
fig.savefig(save_path)
|
|
161
|
+
|
|
162
|
+
if show:
|
|
163
|
+
plt.show()
|
|
164
|
+
|
|
165
|
+
plt.close()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def plot_comparison(
|
|
169
|
+
data1: dict,
|
|
170
|
+
data2: dict,
|
|
171
|
+
labels: list[str] | None = None,
|
|
172
|
+
title: str = "Comparison",
|
|
173
|
+
save_path: str | None = None,
|
|
174
|
+
show: bool = True,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""
|
|
177
|
+
Plot comparison between two clustering methods.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
data1: First dataset with 'points' and 'labels' keys
|
|
181
|
+
data2: Second dataset with 'points' and 'labels' keys
|
|
182
|
+
labels: Labels for the two methods
|
|
183
|
+
title: Plot title
|
|
184
|
+
save_path: Path to save plot (optional)
|
|
185
|
+
show: Whether to display plot
|
|
186
|
+
"""
|
|
187
|
+
if labels is None:
|
|
188
|
+
labels = ["Method 1", "Method 2"]
|
|
189
|
+
|
|
190
|
+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 8))
|
|
191
|
+
|
|
192
|
+
# Plot first method
|
|
193
|
+
plot_clusters_on_axis(ax1, data1["points"], data1["labels"], title=f"{title} - {labels[0]}")
|
|
194
|
+
|
|
195
|
+
# Plot second method
|
|
196
|
+
plot_clusters_on_axis(ax2, data2["points"], data2["labels"], title=f"{title} - {labels[1]}")
|
|
197
|
+
|
|
198
|
+
plt.tight_layout()
|
|
199
|
+
|
|
200
|
+
if save_path:
|
|
201
|
+
fig.savefig(save_path)
|
|
202
|
+
|
|
203
|
+
if show:
|
|
204
|
+
plt.show()
|
|
205
|
+
|
|
206
|
+
plt.close()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def plot_clusters_on_axis(ax, data: np.ndarray, labels: np.ndarray, title: str = "") -> None:
|
|
210
|
+
"""Helper function to plot clusters on a given axis."""
|
|
211
|
+
from matplotlib import colors
|
|
212
|
+
|
|
213
|
+
cvalues = list(colors.cnames.values())
|
|
214
|
+
n_clusters = len(np.unique(labels))
|
|
215
|
+
|
|
216
|
+
for k, col in zip(range(n_clusters), cvalues, strict=False):
|
|
217
|
+
my_members = labels == k
|
|
218
|
+
ax.plot(data[my_members, 0], data[my_members, 1], "w", markerfacecolor=col, marker=".")
|
|
219
|
+
|
|
220
|
+
ax.set_title(title)
|
|
221
|
+
ax.grid(True, alpha=0.3)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def plot_clusters_interactive(
|
|
225
|
+
data: np.ndarray | pd.DataFrame,
|
|
226
|
+
labels: np.ndarray,
|
|
227
|
+
centroids: np.ndarray | None = None,
|
|
228
|
+
title: str = "Interactive Clustering Results",
|
|
229
|
+
save_path: str | None = None,
|
|
230
|
+
) -> folium.Map:
|
|
231
|
+
"""
|
|
232
|
+
Create an interactive map visualization of clustering results using folium.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
data: Data points as numpy array [n, 2] with [lon, lat] or DataFrame with longitude, latitude
|
|
236
|
+
labels: Cluster assignments for each point
|
|
237
|
+
centroids: Optional cluster centers as numpy array [k, 2]
|
|
238
|
+
title: Map title (shown as map title)
|
|
239
|
+
save_path: Path to save HTML file (optional)
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Folium map object that can be displayed in Jupyter or saved to HTML
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
ImportError: If folium is not installed
|
|
246
|
+
"""
|
|
247
|
+
if not HAS_FOLIUM:
|
|
248
|
+
raise ImportError(
|
|
249
|
+
"folium is required for interactive visualization. "
|
|
250
|
+
"Install it with: pip install 'allocator[geo]'"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Convert DataFrame to numpy array if needed
|
|
254
|
+
if isinstance(data, pd.DataFrame):
|
|
255
|
+
if "longitude" in data.columns and "latitude" in data.columns:
|
|
256
|
+
X = data[["longitude", "latitude"]].values
|
|
257
|
+
else:
|
|
258
|
+
raise ValueError("DataFrame must contain 'longitude' and 'latitude' columns")
|
|
259
|
+
else:
|
|
260
|
+
X = data
|
|
261
|
+
|
|
262
|
+
# Calculate map center and zoom
|
|
263
|
+
center_lat = X[:, 1].mean()
|
|
264
|
+
center_lon = X[:, 0].mean()
|
|
265
|
+
|
|
266
|
+
# Calculate bounds for initial zoom
|
|
267
|
+
lat_range = X[:, 1].max() - X[:, 1].min()
|
|
268
|
+
lon_range = X[:, 0].max() - X[:, 0].min()
|
|
269
|
+
max_range = max(lat_range, lon_range)
|
|
270
|
+
|
|
271
|
+
# Estimate appropriate zoom level
|
|
272
|
+
if max_range < 0.01:
|
|
273
|
+
zoom_start = 14
|
|
274
|
+
elif max_range < 0.1:
|
|
275
|
+
zoom_start = 12
|
|
276
|
+
elif max_range < 1.0:
|
|
277
|
+
zoom_start = 10
|
|
278
|
+
else:
|
|
279
|
+
zoom_start = 8
|
|
280
|
+
|
|
281
|
+
# Create base map
|
|
282
|
+
m = folium.Map(location=[center_lat, center_lon], zoom_start=zoom_start, tiles="OpenStreetMap")
|
|
283
|
+
|
|
284
|
+
# Color palette for clusters
|
|
285
|
+
n_clusters = len(np.unique(labels))
|
|
286
|
+
color_palette = [
|
|
287
|
+
"#FF6B6B",
|
|
288
|
+
"#4ECDC4",
|
|
289
|
+
"#45B7D1",
|
|
290
|
+
"#96CEB4",
|
|
291
|
+
"#FECA57",
|
|
292
|
+
"#FF9FF3",
|
|
293
|
+
"#54A0FF",
|
|
294
|
+
"#5F27CD",
|
|
295
|
+
"#00D2D3",
|
|
296
|
+
"#FF9F43",
|
|
297
|
+
"#A55EEA",
|
|
298
|
+
"#26DE81",
|
|
299
|
+
"#FD79A8",
|
|
300
|
+
"#FDCB6E",
|
|
301
|
+
"#6C5CE7",
|
|
302
|
+
]
|
|
303
|
+
|
|
304
|
+
# Extend palette if needed
|
|
305
|
+
while len(color_palette) < n_clusters:
|
|
306
|
+
color_palette.extend(color_palette)
|
|
307
|
+
|
|
308
|
+
# Add data points to map
|
|
309
|
+
for i, (lon, lat) in enumerate(X):
|
|
310
|
+
cluster_id = labels[i]
|
|
311
|
+
color = color_palette[cluster_id % len(color_palette)]
|
|
312
|
+
|
|
313
|
+
folium.CircleMarker(
|
|
314
|
+
location=[lat, lon],
|
|
315
|
+
radius=6,
|
|
316
|
+
popup=f"Point {i}<br>Cluster: {cluster_id}<br>Coords: ({lon:.4f}, {lat:.4f})",
|
|
317
|
+
color="white",
|
|
318
|
+
weight=1,
|
|
319
|
+
fillColor=color,
|
|
320
|
+
fillOpacity=0.8,
|
|
321
|
+
).add_to(m)
|
|
322
|
+
|
|
323
|
+
# Add centroids if provided
|
|
324
|
+
if centroids is not None:
|
|
325
|
+
for k, (lon, lat) in enumerate(centroids):
|
|
326
|
+
color = color_palette[k % len(color_palette)]
|
|
327
|
+
folium.Marker(
|
|
328
|
+
location=[lat, lon],
|
|
329
|
+
popup=f"Centroid {k}<br>Coords: ({lon:.4f}, {lat:.4f})",
|
|
330
|
+
icon=folium.Icon(color="black", icon="star", prefix="fa"),
|
|
331
|
+
tooltip=f"Cluster {k} Centroid",
|
|
332
|
+
).add_to(m)
|
|
333
|
+
|
|
334
|
+
# Add legend
|
|
335
|
+
legend_html = f"""
|
|
336
|
+
<div style="position: fixed;
|
|
337
|
+
top: 10px; left: 50px; width: 200px; height: auto;
|
|
338
|
+
background-color: white; border:2px solid grey; z-index:9999;
|
|
339
|
+
font-size:14px; padding: 10px">
|
|
340
|
+
<h4>{title}</h4>
|
|
341
|
+
<p><strong>Clusters:</strong> {n_clusters}</p>
|
|
342
|
+
<p><strong>Points:</strong> {len(X)}</p>
|
|
343
|
+
"""
|
|
344
|
+
|
|
345
|
+
for k in range(min(n_clusters, 8)): # Show first 8 clusters in legend
|
|
346
|
+
color = color_palette[k]
|
|
347
|
+
legend_html += f'<p><span style="color:{color};">●</span> Cluster {k}</p>'
|
|
348
|
+
|
|
349
|
+
if n_clusters > 8:
|
|
350
|
+
legend_html += f"<p>... and {n_clusters - 8} more</p>"
|
|
351
|
+
|
|
352
|
+
legend_html += "</div>"
|
|
353
|
+
m.get_root().html.add_child(folium.Element(legend_html))
|
|
354
|
+
|
|
355
|
+
# Add fullscreen button
|
|
356
|
+
plugins.Fullscreen().add_to(m)
|
|
357
|
+
|
|
358
|
+
# Save if path provided
|
|
359
|
+
if save_path:
|
|
360
|
+
m.save(save_path)
|
|
361
|
+
|
|
362
|
+
return m
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def plot_route_interactive(
|
|
366
|
+
route_points: np.ndarray,
|
|
367
|
+
route_order: list[int] | None = None,
|
|
368
|
+
route_geometry: str | None = None,
|
|
369
|
+
title: str = "Interactive Route",
|
|
370
|
+
save_path: str | None = None,
|
|
371
|
+
) -> folium.Map:
|
|
372
|
+
"""
|
|
373
|
+
Create an interactive map visualization of TSP/routing results using folium.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
route_points: Points in the route as numpy array [n, 2] with [lon, lat]
|
|
377
|
+
route_order: Order to visit points (optional, defaults to sequential)
|
|
378
|
+
route_geometry: Optional encoded polyline string from routing API (OSRM/Google)
|
|
379
|
+
title: Map title
|
|
380
|
+
save_path: Path to save HTML file (optional)
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
Folium map object that can be displayed in Jupyter or saved to HTML
|
|
384
|
+
|
|
385
|
+
Raises:
|
|
386
|
+
ImportError: If folium is not installed
|
|
387
|
+
"""
|
|
388
|
+
if not HAS_FOLIUM:
|
|
389
|
+
raise ImportError(
|
|
390
|
+
"folium is required for interactive route visualization. "
|
|
391
|
+
"Install it with: pip install 'allocator[geo]'"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
if route_order is None:
|
|
395
|
+
route_order = list(range(len(route_points)))
|
|
396
|
+
|
|
397
|
+
# Calculate map center and bounds
|
|
398
|
+
center_lat = route_points[:, 1].mean()
|
|
399
|
+
center_lon = route_points[:, 0].mean()
|
|
400
|
+
|
|
401
|
+
lat_range = route_points[:, 1].max() - route_points[:, 1].min()
|
|
402
|
+
lon_range = route_points[:, 0].max() - route_points[:, 0].min()
|
|
403
|
+
max_range = max(lat_range, lon_range)
|
|
404
|
+
|
|
405
|
+
# Estimate zoom level
|
|
406
|
+
if max_range < 0.01:
|
|
407
|
+
zoom_start = 14
|
|
408
|
+
elif max_range < 0.1:
|
|
409
|
+
zoom_start = 12
|
|
410
|
+
elif max_range < 1.0:
|
|
411
|
+
zoom_start = 10
|
|
412
|
+
else:
|
|
413
|
+
zoom_start = 8
|
|
414
|
+
|
|
415
|
+
# Create base map
|
|
416
|
+
m = folium.Map(location=[center_lat, center_lon], zoom_start=zoom_start, tiles="OpenStreetMap")
|
|
417
|
+
|
|
418
|
+
# Add route line
|
|
419
|
+
if route_geometry and HAS_POLYLINE:
|
|
420
|
+
# Decode polyline geometry from routing API
|
|
421
|
+
try:
|
|
422
|
+
decoded_coords = polyline.decode(route_geometry)
|
|
423
|
+
# Convert to [lat, lon] format for folium
|
|
424
|
+
route_coords = [[lat, lon] for lat, lon in decoded_coords]
|
|
425
|
+
|
|
426
|
+
folium.PolyLine(
|
|
427
|
+
locations=route_coords, color="blue", weight=4, opacity=0.8, popup="Optimized Route"
|
|
428
|
+
).add_to(m)
|
|
429
|
+
except Exception:
|
|
430
|
+
# Fall back to straight line connections if decoding fails
|
|
431
|
+
_add_straight_line_route(m, route_points, route_order)
|
|
432
|
+
else:
|
|
433
|
+
# Use straight line connections between points
|
|
434
|
+
_add_straight_line_route(m, route_points, route_order)
|
|
435
|
+
|
|
436
|
+
# Add route points
|
|
437
|
+
for i, point_idx in enumerate(route_order):
|
|
438
|
+
lon, lat = route_points[point_idx]
|
|
439
|
+
|
|
440
|
+
# Color-code start, end, and intermediate points
|
|
441
|
+
if i == 0:
|
|
442
|
+
icon_color = "green"
|
|
443
|
+
icon_symbol = "play"
|
|
444
|
+
label = "Start"
|
|
445
|
+
elif i == len(route_order) - 1:
|
|
446
|
+
icon_color = "red"
|
|
447
|
+
icon_symbol = "stop"
|
|
448
|
+
label = "End"
|
|
449
|
+
else:
|
|
450
|
+
icon_color = "blue"
|
|
451
|
+
icon_symbol = f"{i}"
|
|
452
|
+
label = f"Stop {i}"
|
|
453
|
+
|
|
454
|
+
folium.Marker(
|
|
455
|
+
location=[lat, lon],
|
|
456
|
+
popup=f"{label}<br>Point {point_idx}<br>Coords: ({lon:.4f}, {lat:.4f})",
|
|
457
|
+
icon=folium.Icon(color=icon_color, icon=icon_symbol, prefix="fa"),
|
|
458
|
+
tooltip=f"{label} (Point {point_idx})",
|
|
459
|
+
).add_to(m)
|
|
460
|
+
|
|
461
|
+
# Calculate route statistics
|
|
462
|
+
total_distance = _calculate_route_distance(route_points, route_order)
|
|
463
|
+
|
|
464
|
+
# Add info panel
|
|
465
|
+
info_html = f"""
|
|
466
|
+
<div style="position: fixed;
|
|
467
|
+
top: 10px; right: 10px; width: 250px; height: auto;
|
|
468
|
+
background-color: white; border:2px solid grey; z-index:9999;
|
|
469
|
+
font-size:14px; padding: 10px">
|
|
470
|
+
<h4>{title}</h4>
|
|
471
|
+
<p><strong>Points:</strong> {len(route_points)}</p>
|
|
472
|
+
<p><strong>Route Type:</strong> {"API Route" if route_geometry else "Direct Lines"}</p>
|
|
473
|
+
<p><strong>Est. Distance:</strong> {total_distance:.2f} km</p>
|
|
474
|
+
<hr>
|
|
475
|
+
<p><span style="color:green;">●</span> Start Point</p>
|
|
476
|
+
<p><span style="color:blue;">●</span> Intermediate</p>
|
|
477
|
+
<p><span style="color:red;">●</span> End Point</p>
|
|
478
|
+
</div>
|
|
479
|
+
"""
|
|
480
|
+
m.get_root().html.add_child(folium.Element(info_html))
|
|
481
|
+
|
|
482
|
+
# Add fullscreen button
|
|
483
|
+
plugins.Fullscreen().add_to(m)
|
|
484
|
+
|
|
485
|
+
# Save if path provided
|
|
486
|
+
if save_path:
|
|
487
|
+
m.save(save_path)
|
|
488
|
+
|
|
489
|
+
return m
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _add_straight_line_route(
|
|
493
|
+
m: folium.Map, route_points: np.ndarray, route_order: list[int]
|
|
494
|
+
) -> None:
|
|
495
|
+
"""Add straight line connections between route points."""
|
|
496
|
+
ordered_points = route_points[route_order]
|
|
497
|
+
|
|
498
|
+
# Create route coordinates including return to start
|
|
499
|
+
route_coords = [[lat, lon] for lon, lat in ordered_points]
|
|
500
|
+
route_coords.append([ordered_points[0][1], ordered_points[0][0]]) # Return to start
|
|
501
|
+
|
|
502
|
+
folium.PolyLine(
|
|
503
|
+
locations=route_coords,
|
|
504
|
+
color="blue",
|
|
505
|
+
weight=3,
|
|
506
|
+
opacity=0.7,
|
|
507
|
+
popup="Direct Route (Straight Lines)",
|
|
508
|
+
).add_to(m)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _calculate_route_distance(route_points: np.ndarray, route_order: list[int]) -> float:
|
|
512
|
+
"""Calculate approximate route distance using haversine formula."""
|
|
513
|
+
from math import asin, cos, radians, sin, sqrt
|
|
514
|
+
|
|
515
|
+
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
516
|
+
"""Calculate distance between two points on Earth using haversine formula."""
|
|
517
|
+
# Convert to radians
|
|
518
|
+
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
|
|
519
|
+
|
|
520
|
+
# Haversine formula
|
|
521
|
+
dlat = lat2 - lat1
|
|
522
|
+
dlon = lon2 - lon1
|
|
523
|
+
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
|
|
524
|
+
c = 2 * asin(sqrt(a))
|
|
525
|
+
|
|
526
|
+
# Earth radius in kilometers
|
|
527
|
+
r = 6371
|
|
528
|
+
return c * r
|
|
529
|
+
|
|
530
|
+
total_distance = 0.0
|
|
531
|
+
ordered_points = route_points[route_order]
|
|
532
|
+
|
|
533
|
+
# Calculate distances between consecutive points
|
|
534
|
+
for i in range(len(ordered_points)):
|
|
535
|
+
current_point = ordered_points[i]
|
|
536
|
+
next_point = ordered_points[(i + 1) % len(ordered_points)] # Return to start
|
|
537
|
+
|
|
538
|
+
distance = haversine_distance(
|
|
539
|
+
current_point[1],
|
|
540
|
+
current_point[0], # lat1, lon1
|
|
541
|
+
next_point[1],
|
|
542
|
+
next_point[0], # lat2, lon2
|
|
543
|
+
)
|
|
544
|
+
total_distance += distance
|
|
545
|
+
|
|
546
|
+
return total_distance
|
|
@@ -8,7 +8,7 @@ module-root = ""
|
|
|
8
8
|
|
|
9
9
|
[project]
|
|
10
10
|
name = "allocator"
|
|
11
|
-
version = "1.
|
|
11
|
+
version = "1.1.0"
|
|
12
12
|
description = "Modern Python package for geographic task allocation, clustering, and routing optimization"
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
requires-python = ">=3.11"
|
|
@@ -44,7 +44,6 @@ dependencies = [
|
|
|
44
44
|
# Core data processing
|
|
45
45
|
"pandas>=2.0.0",
|
|
46
46
|
"numpy>=1.24.0",
|
|
47
|
-
"scikit-learn>=1.3.0",
|
|
48
47
|
# Distance calculations
|
|
49
48
|
"utm>=0.7.0",
|
|
50
49
|
"haversine>=2.8.0",
|
|
@@ -67,8 +66,8 @@ dependencies = [
|
|
|
67
66
|
[project.optional-dependencies]
|
|
68
67
|
# Algorithm-specific dependencies
|
|
69
68
|
algorithms = [
|
|
70
|
-
"
|
|
71
|
-
"
|
|
69
|
+
"Christofides>=1.0.0", # Christofides TSP algorithm
|
|
70
|
+
"scikit-learn>=1.3.0", # Machine learning algorithms
|
|
72
71
|
]
|
|
73
72
|
geo = [
|
|
74
73
|
"folium>=0.14.0", # Interactive maps
|
|
@@ -216,7 +215,18 @@ module = [
|
|
|
216
215
|
]
|
|
217
216
|
ignore_missing_imports = true
|
|
218
217
|
|
|
218
|
+
# Deptry configuration
|
|
219
|
+
[tool.deptry]
|
|
220
|
+
pep621_dev_dependency_groups = ["dev", "test", "docs"]
|
|
221
|
+
|
|
222
|
+
[tool.deptry.per_rule_ignores]
|
|
223
|
+
DEP001 = ["Christofides"] # Christofides is in optional algorithms group
|
|
224
|
+
DEP002 = ["Christofides"] # Christofides is correctly used in routing code
|
|
225
|
+
|
|
226
|
+
[tool.deptry.package_module_name_map]
|
|
227
|
+
Christofides = "christofides"
|
|
228
|
+
linkify-it-py = "linkify_it_py"
|
|
219
229
|
|
|
220
230
|
[dependency-groups]
|
|
221
231
|
dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.7.0", "mypy>=1.0"]
|
|
222
|
-
test = ["pytest>=7.0", "pytest-cov>=4.0"]
|
|
232
|
+
test = ["pytest>=7.0", "pytest-cov>=4.0"]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""Core algorithms for clustering and optimization."""
|
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Visualization utilities for allocator package.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from __future__ import annotations
|
|
6
|
-
|
|
7
|
-
import matplotlib.pyplot as plt
|
|
8
|
-
import numpy as np
|
|
9
|
-
import pandas as pd
|
|
10
|
-
from matplotlib import colors
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def plot_clusters(
|
|
14
|
-
data: np.ndarray | pd.DataFrame,
|
|
15
|
-
labels: np.ndarray,
|
|
16
|
-
centroids: np.ndarray | None = None,
|
|
17
|
-
title: str = "Clustering Results",
|
|
18
|
-
save_path: str | None = None,
|
|
19
|
-
show: bool = True,
|
|
20
|
-
) -> None:
|
|
21
|
-
"""
|
|
22
|
-
Plot clustering results.
|
|
23
|
-
|
|
24
|
-
Args:
|
|
25
|
-
data: Data points as numpy array [n, 2] with [lon, lat] or DataFrame with longitude, latitude
|
|
26
|
-
labels: Cluster assignments for each point
|
|
27
|
-
centroids: Optional cluster centers as numpy array [k, 2]
|
|
28
|
-
title: Plot title
|
|
29
|
-
save_path: Path to save plot (optional)
|
|
30
|
-
show: Whether to display plot
|
|
31
|
-
"""
|
|
32
|
-
# Convert DataFrame to numpy array if needed
|
|
33
|
-
if isinstance(data, pd.DataFrame):
|
|
34
|
-
if "longitude" in data.columns and "latitude" in data.columns:
|
|
35
|
-
X = data[["longitude", "latitude"]].values
|
|
36
|
-
else:
|
|
37
|
-
raise ValueError("DataFrame must contain 'longitude' and 'latitude' columns")
|
|
38
|
-
else:
|
|
39
|
-
X = data
|
|
40
|
-
|
|
41
|
-
fig = plt.figure(figsize=(8, 8))
|
|
42
|
-
plt.ticklabel_format(useOffset=False)
|
|
43
|
-
cvalues = list(colors.cnames.values())
|
|
44
|
-
|
|
45
|
-
n_clusters = len(np.unique(labels))
|
|
46
|
-
|
|
47
|
-
ax = fig.add_subplot(1, 1, 1)
|
|
48
|
-
for k, col in zip(range(n_clusters), cvalues, strict=False):
|
|
49
|
-
my_members = labels == k
|
|
50
|
-
ax.plot(X[my_members, 0], X[my_members, 1], "w", markerfacecolor=col, marker=".")
|
|
51
|
-
|
|
52
|
-
if centroids is not None:
|
|
53
|
-
ax.plot(
|
|
54
|
-
centroids[k][0],
|
|
55
|
-
centroids[k][1],
|
|
56
|
-
"o",
|
|
57
|
-
markerfacecolor=col,
|
|
58
|
-
markeredgecolor="k",
|
|
59
|
-
markersize=6,
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
ax.set_title(title)
|
|
63
|
-
# ax.set_xticks(())
|
|
64
|
-
# ax.set_yticks(())
|
|
65
|
-
|
|
66
|
-
if save_path:
|
|
67
|
-
fig.savefig(save_path)
|
|
68
|
-
|
|
69
|
-
if show:
|
|
70
|
-
plt.show()
|
|
71
|
-
|
|
72
|
-
plt.close()
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def plot_assignments(
|
|
76
|
-
data: pd.DataFrame,
|
|
77
|
-
title: str = "Assignment Results",
|
|
78
|
-
save_path: str | None = None,
|
|
79
|
-
show: bool = True,
|
|
80
|
-
) -> None:
|
|
81
|
-
"""
|
|
82
|
-
Plot assignment results for sort_by_distance type algorithms.
|
|
83
|
-
|
|
84
|
-
Args:
|
|
85
|
-
data: DataFrame with longitude, latitude, and assigned_points columns
|
|
86
|
-
title: Plot title
|
|
87
|
-
save_path: Path to save plot (optional)
|
|
88
|
-
show: Whether to display plot
|
|
89
|
-
"""
|
|
90
|
-
|
|
91
|
-
if "assigned_points" not in data.columns:
|
|
92
|
-
raise ValueError("DataFrame must contain 'assigned_points' column")
|
|
93
|
-
|
|
94
|
-
X = data[["longitude", "latitude"]].values
|
|
95
|
-
labels = data["assigned_points"].values - 1 # Convert to 0-based indexing
|
|
96
|
-
|
|
97
|
-
# Use the main cluster plotting function
|
|
98
|
-
plot_clusters(X, labels, centroids=None, title=title, save_path=save_path, show=show)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def plot_route(
|
|
102
|
-
route_points: np.ndarray,
|
|
103
|
-
route_order: list[int] | None = None,
|
|
104
|
-
title: str = "Route",
|
|
105
|
-
save_path: str | None = None,
|
|
106
|
-
show: bool = True,
|
|
107
|
-
) -> None:
|
|
108
|
-
"""
|
|
109
|
-
Plot TSP/routing results.
|
|
110
|
-
|
|
111
|
-
Args:
|
|
112
|
-
route_points: Points in the route as numpy array [n, 2] with [lon, lat]
|
|
113
|
-
route_order: Order to visit points (optional, defaults to sequential)
|
|
114
|
-
title: Plot title
|
|
115
|
-
save_path: Path to save plot (optional)
|
|
116
|
-
show: Whether to display plot
|
|
117
|
-
"""
|
|
118
|
-
if route_order is None:
|
|
119
|
-
route_order = list(range(len(route_points)))
|
|
120
|
-
|
|
121
|
-
fig = plt.figure(figsize=(10, 8))
|
|
122
|
-
ax = fig.add_subplot(1, 1, 1)
|
|
123
|
-
|
|
124
|
-
# Plot points
|
|
125
|
-
ax.scatter(route_points[:, 0], route_points[:, 1], c="red", s=50, zorder=5)
|
|
126
|
-
|
|
127
|
-
# Plot route
|
|
128
|
-
ordered_points = route_points[route_order]
|
|
129
|
-
# Add return to start
|
|
130
|
-
route_x = np.append(ordered_points[:, 0], ordered_points[0, 0])
|
|
131
|
-
route_y = np.append(ordered_points[:, 1], ordered_points[0, 1])
|
|
132
|
-
|
|
133
|
-
ax.plot(route_x, route_y, "b-", linewidth=2, alpha=0.7)
|
|
134
|
-
|
|
135
|
-
# Add point labels
|
|
136
|
-
for i, (x, y) in enumerate(route_points):
|
|
137
|
-
ax.annotate(str(i), (x, y), xytext=(5, 5), textcoords="offset points")
|
|
138
|
-
|
|
139
|
-
ax.set_title(title)
|
|
140
|
-
ax.set_xlabel("Longitude")
|
|
141
|
-
ax.set_ylabel("Latitude")
|
|
142
|
-
ax.grid(True, alpha=0.3)
|
|
143
|
-
|
|
144
|
-
if save_path:
|
|
145
|
-
fig.savefig(save_path)
|
|
146
|
-
|
|
147
|
-
if show:
|
|
148
|
-
plt.show()
|
|
149
|
-
|
|
150
|
-
plt.close()
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def plot_comparison(
|
|
154
|
-
data1: dict,
|
|
155
|
-
data2: dict,
|
|
156
|
-
labels: list[str] | None = None,
|
|
157
|
-
title: str = "Comparison",
|
|
158
|
-
save_path: str | None = None,
|
|
159
|
-
show: bool = True,
|
|
160
|
-
) -> None:
|
|
161
|
-
"""
|
|
162
|
-
Plot comparison between two clustering methods.
|
|
163
|
-
|
|
164
|
-
Args:
|
|
165
|
-
data1: First dataset with 'points' and 'labels' keys
|
|
166
|
-
data2: Second dataset with 'points' and 'labels' keys
|
|
167
|
-
labels: Labels for the two methods
|
|
168
|
-
title: Plot title
|
|
169
|
-
save_path: Path to save plot (optional)
|
|
170
|
-
show: Whether to display plot
|
|
171
|
-
"""
|
|
172
|
-
if labels is None:
|
|
173
|
-
labels = ["Method 1", "Method 2"]
|
|
174
|
-
|
|
175
|
-
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 8))
|
|
176
|
-
|
|
177
|
-
# Plot first method
|
|
178
|
-
plot_clusters_on_axis(ax1, data1["points"], data1["labels"], title=f"{title} - {labels[0]}")
|
|
179
|
-
|
|
180
|
-
# Plot second method
|
|
181
|
-
plot_clusters_on_axis(ax2, data2["points"], data2["labels"], title=f"{title} - {labels[1]}")
|
|
182
|
-
|
|
183
|
-
plt.tight_layout()
|
|
184
|
-
|
|
185
|
-
if save_path:
|
|
186
|
-
fig.savefig(save_path)
|
|
187
|
-
|
|
188
|
-
if show:
|
|
189
|
-
plt.show()
|
|
190
|
-
|
|
191
|
-
plt.close()
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def plot_clusters_on_axis(ax, data: np.ndarray, labels: np.ndarray, title: str = "") -> None:
|
|
195
|
-
"""Helper function to plot clusters on a given axis."""
|
|
196
|
-
from matplotlib import colors
|
|
197
|
-
|
|
198
|
-
cvalues = list(colors.cnames.values())
|
|
199
|
-
n_clusters = len(np.unique(labels))
|
|
200
|
-
|
|
201
|
-
for k, col in zip(range(n_clusters), cvalues, strict=False):
|
|
202
|
-
my_members = labels == k
|
|
203
|
-
ax.plot(data[my_members, 0], data[my_members, 1], "w", markerfacecolor=col, marker=".")
|
|
204
|
-
|
|
205
|
-
ax.set_title(title)
|
|
206
|
-
ax.grid(True, alpha=0.3)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|