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.
Files changed (29) hide show
  1. {allocator-1.0.0 → allocator-1.1.0}/PKG-INFO +2 -3
  2. allocator-1.1.0/allocator/core/__init__.py +15 -0
  3. {allocator-1.0.0 → allocator-1.1.0}/allocator/core/algorithms.py +159 -10
  4. {allocator-1.0.0 → allocator-1.1.0}/allocator/viz/__init__.py +4 -0
  5. allocator-1.1.0/allocator/viz/plotting.py +546 -0
  6. {allocator-1.0.0 → allocator-1.1.0}/pyproject.toml +15 -5
  7. allocator-1.0.0/allocator/core/__init__.py +0 -1
  8. allocator-1.0.0/allocator/viz/plotting.py +0 -206
  9. {allocator-1.0.0 → allocator-1.1.0}/README.md +0 -0
  10. {allocator-1.0.0 → allocator-1.1.0}/allocator/__init__.py +0 -0
  11. {allocator-1.0.0 → allocator-1.1.0}/allocator/api/__init__.py +0 -0
  12. {allocator-1.0.0 → allocator-1.1.0}/allocator/api/cluster.py +0 -0
  13. {allocator-1.0.0 → allocator-1.1.0}/allocator/api/distance.py +0 -0
  14. {allocator-1.0.0 → allocator-1.1.0}/allocator/api/route.py +0 -0
  15. {allocator-1.0.0 → allocator-1.1.0}/allocator/api/types.py +0 -0
  16. {allocator-1.0.0 → allocator-1.1.0}/allocator/cli/__init__.py +0 -0
  17. {allocator-1.0.0 → allocator-1.1.0}/allocator/cli/cluster_cmd.py +0 -0
  18. {allocator-1.0.0 → allocator-1.1.0}/allocator/cli/main.py +0 -0
  19. {allocator-1.0.0 → allocator-1.1.0}/allocator/cli/route_cmd.py +0 -0
  20. {allocator-1.0.0 → allocator-1.1.0}/allocator/core/routing.py +0 -0
  21. {allocator-1.0.0 → allocator-1.1.0}/allocator/distances/__init__.py +0 -0
  22. {allocator-1.0.0 → allocator-1.1.0}/allocator/distances/euclidean.py +0 -0
  23. {allocator-1.0.0 → allocator-1.1.0}/allocator/distances/external_apis.py +0 -0
  24. {allocator-1.0.0 → allocator-1.1.0}/allocator/distances/factory.py +0 -0
  25. {allocator-1.0.0 → allocator-1.1.0}/allocator/distances/haversine.py +0 -0
  26. {allocator-1.0.0 → allocator-1.1.0}/allocator/io/__init__.py +0 -0
  27. {allocator-1.0.0 → allocator-1.1.0}/allocator/io/data_handler.py +0 -0
  28. {allocator-1.0.0 → allocator-1.1.0}/allocator/py.typed +0 -0
  29. {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.0.0
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
- Pure K-means clustering implementation.
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 DataFrame with longitude/latitude or numpy array [n, 2]
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
- # 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)
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.0.0"
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
- "scipy>=1.10.0", # Enhanced algorithms
71
- "Christofides>=1.0.0", # Christofides TSP algorithm
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