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.
@@ -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)