multimodalrouter 0.1.14__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.
- multimodalrouter/__init__.py +7 -0
- multimodalrouter/graph/__init__.py +2 -0
- multimodalrouter/graph/dataclasses.py +220 -0
- multimodalrouter/graph/graph.py +798 -0
- multimodalrouter/graphics/__init__.py +1 -0
- multimodalrouter/graphics/graphicsWrapper.py +323 -0
- multimodalrouter/router/__init__.py +0 -0
- multimodalrouter/router/build.py +97 -0
- multimodalrouter/router/route.py +71 -0
- multimodalrouter/utils/__init__.py +1 -0
- multimodalrouter/utils/preprocessor.py +177 -0
- multimodalrouter-0.1.14.dist-info/METADATA +131 -0
- multimodalrouter-0.1.14.dist-info/RECORD +18 -0
- multimodalrouter-0.1.14.dist-info/WHEEL +5 -0
- multimodalrouter-0.1.14.dist-info/entry_points.txt +3 -0
- multimodalrouter-0.1.14.dist-info/licenses/LICENSE.md +10 -0
- multimodalrouter-0.1.14.dist-info/licenses/NOTICE.md +44 -0
- multimodalrouter-0.1.14.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
# graph.py
|
|
2
|
+
# Copyright (c) 2025 Tobias Karusseit
|
|
3
|
+
# Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
from tqdm import tqdm
|
|
7
|
+
import zlib
|
|
8
|
+
import dill
|
|
9
|
+
import heapq
|
|
10
|
+
import os
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from .dataclasses import Hub, EdgeMetadata, OptimizationMetric, Route, Filter, VerboseRoute, PathNode
|
|
13
|
+
from threading import Lock
|
|
14
|
+
from collections import defaultdict, deque
|
|
15
|
+
from itertools import count
|
|
16
|
+
from typing import Tuple, TypeAlias
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RouteGraph:
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
# sets the max distance to connect hubs with driving edges
|
|
25
|
+
maxDistance: float,
|
|
26
|
+
# dict like {hubtype -> "airport": "fly", "shippingport": "shipping"}
|
|
27
|
+
transportModes: dict[str, str],
|
|
28
|
+
# dict like {hubtype -> "airport": path -> "airports.csv", "shippingport": "shippingports.csv"}
|
|
29
|
+
dataPaths: dict[str, str] = {},
|
|
30
|
+
# if true model file will be compressed otherwise normal .dill file
|
|
31
|
+
compressed: bool = False,
|
|
32
|
+
# list of extra columns to add to the edge metadata (dynamically added to links when key is present in dataser)
|
|
33
|
+
extraMetricsKeys: list[str] = [],
|
|
34
|
+
# if true will connect hubs with driving edges
|
|
35
|
+
drivingEnabled: bool = True,
|
|
36
|
+
# a list of coordinate names for the source coords in the datasets (name to dataset matching is automatic)
|
|
37
|
+
sourceCoordKeys: list[str] = ["source_lat", "source_lng"],
|
|
38
|
+
# a list of coordinate names for the destination coords in the datasets (name to dataset matching is automatic)
|
|
39
|
+
destCoordKeys: list[str] = ["destination_lat", "destination_lng"],
|
|
40
|
+
):
|
|
41
|
+
self.sourceCoordKeys = set(sourceCoordKeys)
|
|
42
|
+
self.destCoordKeys = set(destCoordKeys)
|
|
43
|
+
|
|
44
|
+
self.compressed = compressed
|
|
45
|
+
self.extraMetricsKeys = extraMetricsKeys
|
|
46
|
+
self.drivingEnabled = drivingEnabled
|
|
47
|
+
|
|
48
|
+
self.TransportModes = transportModes
|
|
49
|
+
# hubtype -> {hubid -> Hub}
|
|
50
|
+
self.Graph: dict[str, dict[str, Hub]] = {}
|
|
51
|
+
|
|
52
|
+
# save the paths to the data in the state dict
|
|
53
|
+
for key, value in dataPaths.items():
|
|
54
|
+
setattr(self, key + "DataPath", value)
|
|
55
|
+
self.Graph[key] = {}
|
|
56
|
+
|
|
57
|
+
self.maxDrivingDistance = maxDistance
|
|
58
|
+
|
|
59
|
+
self._lock = Lock()
|
|
60
|
+
|
|
61
|
+
def __getstate__(self):
|
|
62
|
+
state = self.__dict__.copy()
|
|
63
|
+
|
|
64
|
+
# remove attributes that break pickle
|
|
65
|
+
if "_lock" in state:
|
|
66
|
+
del state["_lock"]
|
|
67
|
+
|
|
68
|
+
return state
|
|
69
|
+
|
|
70
|
+
def __setstate__(self, state):
|
|
71
|
+
self.__dict__.update(state)
|
|
72
|
+
# set the lock for thread safety
|
|
73
|
+
from threading import Lock
|
|
74
|
+
self._lock = Lock()
|
|
75
|
+
|
|
76
|
+
# =========== public helpers ==========
|
|
77
|
+
|
|
78
|
+
def findClosestHub(self, allowedHubTypes: list[str], coords: list[float]) -> Hub | None:
|
|
79
|
+
"""
|
|
80
|
+
Find the closest hub of a given type to a given location.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
hubType: Type of hub to find
|
|
84
|
+
coords: list[float] = the coordinates of the location
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Hub instance if found, None otherwise
|
|
88
|
+
"""
|
|
89
|
+
potentialHubs = []
|
|
90
|
+
if allowedHubTypes is not None:
|
|
91
|
+
for hubType in allowedHubTypes:
|
|
92
|
+
potentialHubs.extend(self.Graph.get(hubType, {}).values())
|
|
93
|
+
|
|
94
|
+
if allowedHubTypes is None:
|
|
95
|
+
potentialHubs = list(self._allHubs())
|
|
96
|
+
|
|
97
|
+
if not potentialHubs:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
tempHub = Hub(coords=coords, hubType="temp", id="temp")
|
|
101
|
+
distances = self._hubToHubDistances([tempHub], potentialHubs).flatten() # shape (n,)
|
|
102
|
+
closest_hub = potentialHubs[distances.argmin()]
|
|
103
|
+
return closest_hub
|
|
104
|
+
|
|
105
|
+
def addHub(self, hub: Hub):
|
|
106
|
+
"""
|
|
107
|
+
Add a hub to the graph. If the hub already exists, it will not be added a second time.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
hub: Hub instance to add to the graph
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
None
|
|
114
|
+
"""
|
|
115
|
+
with self._lock:
|
|
116
|
+
hubType = hub.hubType
|
|
117
|
+
# if the hub type doesnt exist in the graph, create it first
|
|
118
|
+
if hubType not in self.Graph:
|
|
119
|
+
self.Graph[hubType] = {}
|
|
120
|
+
# exit if the hub already exists
|
|
121
|
+
if hub.id in self.Graph[hubType]:
|
|
122
|
+
return
|
|
123
|
+
# add the hub
|
|
124
|
+
self.Graph[hubType][hub.id] = hub
|
|
125
|
+
|
|
126
|
+
def getHub(self, hubType: str, id: str) -> Hub | None:
|
|
127
|
+
"""
|
|
128
|
+
Get a hub from the graph by hub type and hub id.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
hubType: Type of hub to get
|
|
132
|
+
id: ID of the hub to get
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Hub instance if found, None otherwise
|
|
136
|
+
"""
|
|
137
|
+
return self.Graph.get(hubType, {}).get(id)
|
|
138
|
+
|
|
139
|
+
def getHubById(self, id: str) -> Hub | None:
|
|
140
|
+
"""
|
|
141
|
+
Get a hub from the graph by its ID.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
id: ID of the hub to get
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Hub instance if found, None otherwise
|
|
148
|
+
"""
|
|
149
|
+
for hubType in self.Graph:
|
|
150
|
+
hub = self.Graph[hubType].get(id)
|
|
151
|
+
if hub:
|
|
152
|
+
return hub
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
def save(
|
|
156
|
+
self,
|
|
157
|
+
filepath: str = os.path.join(os.getcwd(), "..", "..", "..", "data"),
|
|
158
|
+
compressed: bool = False
|
|
159
|
+
):
|
|
160
|
+
"""
|
|
161
|
+
Save the RouteGraph to a file.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
filepath (str): Path to save the graph to.
|
|
165
|
+
saveMode (str, optional): Unused. Defaults to None.
|
|
166
|
+
compressed (bool, optional): Whether to compress the saved graph. Defaults to False.
|
|
167
|
+
|
|
168
|
+
Note: The graph is saved in the following format:
|
|
169
|
+
- Compressed: <filepath>.zlib
|
|
170
|
+
- Uncompressed: <filepath>.dill
|
|
171
|
+
"""
|
|
172
|
+
with self._lock:
|
|
173
|
+
|
|
174
|
+
# ensure correct compression type is set for loading the graph
|
|
175
|
+
self.compressed = compressed
|
|
176
|
+
os.makedirs(filepath, exist_ok=True)
|
|
177
|
+
# save the graph
|
|
178
|
+
pickled = dill.dumps(self)
|
|
179
|
+
if compressed:
|
|
180
|
+
compressed = zlib.compress(pickled)
|
|
181
|
+
with open(os.path.join(filepath, "graph.zlib"), "wb") as f:
|
|
182
|
+
f.write(compressed)
|
|
183
|
+
else:
|
|
184
|
+
with open(os.path.join(filepath, "graph.dill"), "wb") as f:
|
|
185
|
+
f.write(pickled)
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def load(filepath: str, compressed: bool = False) -> "RouteGraph":
|
|
189
|
+
"""
|
|
190
|
+
Load a RouteGraph from a file.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
filepath (str): Path to load the graph from.
|
|
194
|
+
compressed (bool, optional): Whether the saved graph is compressed. Defaults to False.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
RouteGraph: The loaded graph.
|
|
198
|
+
"""
|
|
199
|
+
with open(filepath, "rb") as f:
|
|
200
|
+
file_data = f.read()
|
|
201
|
+
if compressed:
|
|
202
|
+
decompressed = zlib.decompress(file_data)
|
|
203
|
+
graph = dill.loads(decompressed)
|
|
204
|
+
else:
|
|
205
|
+
graph = dill.loads(file_data)
|
|
206
|
+
return graph
|
|
207
|
+
|
|
208
|
+
# ============= private helpers =============
|
|
209
|
+
|
|
210
|
+
def _allHubs(self):
|
|
211
|
+
for hubType in self.Graph:
|
|
212
|
+
yield from self.Graph[hubType].values()
|
|
213
|
+
|
|
214
|
+
def _addLink(
|
|
215
|
+
self,
|
|
216
|
+
hub1: Hub,
|
|
217
|
+
hub2: Hub,
|
|
218
|
+
mode: str,
|
|
219
|
+
distance: float,
|
|
220
|
+
bidirectional: bool = False,
|
|
221
|
+
extraData: dict | None = None
|
|
222
|
+
):
|
|
223
|
+
"""
|
|
224
|
+
Add a connection between two hubs, dynamically storing extra metrics.
|
|
225
|
+
"""
|
|
226
|
+
with self._lock:
|
|
227
|
+
if extraData is None:
|
|
228
|
+
extraData = {}
|
|
229
|
+
# combine required metrics with extra
|
|
230
|
+
metrics = {"distance": distance, **extraData}
|
|
231
|
+
edge = EdgeMetadata(transportMode=mode, **metrics)
|
|
232
|
+
hub1.addOutgoing(mode, hub2.id, edge)
|
|
233
|
+
if bidirectional:
|
|
234
|
+
hub2.addOutgoing(mode, hub1.id, edge.copy())
|
|
235
|
+
|
|
236
|
+
def _loadData(self, targetHubType: str):
|
|
237
|
+
dataPath = getattr(self, targetHubType + "DataPath")
|
|
238
|
+
if dataPath is None:
|
|
239
|
+
raise ValueError(f"Data path for {targetHubType} is not set")
|
|
240
|
+
fType = os.path.splitext(dataPath)[1]
|
|
241
|
+
if fType == ".csv":
|
|
242
|
+
data = pd.read_csv(dataPath)
|
|
243
|
+
elif fType == ".parquet":
|
|
244
|
+
data = pd.read_parquet(dataPath)
|
|
245
|
+
else:
|
|
246
|
+
raise ValueError(f"Unsupported file type {fType}")
|
|
247
|
+
|
|
248
|
+
if data.empty:
|
|
249
|
+
raise ValueError(f"{targetHubType} data is empty load the data first")
|
|
250
|
+
|
|
251
|
+
return data
|
|
252
|
+
|
|
253
|
+
def _generateHubs(self):
|
|
254
|
+
"""
|
|
255
|
+
Generate Hub instances and link them with EdgeMetadata.
|
|
256
|
+
Extra columns in the data will be added to EdgeMetadata dynamically.
|
|
257
|
+
"""
|
|
258
|
+
# no lock needed since underlying methods have locks
|
|
259
|
+
for hubType in self.Graph.keys():
|
|
260
|
+
data = self._loadData(hubType)
|
|
261
|
+
added = set()
|
|
262
|
+
|
|
263
|
+
thisSourceKeys = self.sourceCoordKeys & set(data.columns)
|
|
264
|
+
thisDestinationKeys = self.destCoordKeys & set(data.columns)
|
|
265
|
+
|
|
266
|
+
# get required and extra columns
|
|
267
|
+
required_cols = {
|
|
268
|
+
"source", "destination",
|
|
269
|
+
*thisSourceKeys,
|
|
270
|
+
*thisDestinationKeys,
|
|
271
|
+
"distance"
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
# collect extra data from the dataset columns that are not required but marked as extra
|
|
275
|
+
extra_metric_cols = []
|
|
276
|
+
for m in self.extraMetricsKeys:
|
|
277
|
+
if m not in required_cols:
|
|
278
|
+
try:
|
|
279
|
+
extra_metric_cols.append(m)
|
|
280
|
+
except KeyError:
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
for row in tqdm(data.itertuples(index=False), desc=f"Generating {hubType} Hubs", unit="hub"):
|
|
284
|
+
# create hubs if they don't exist
|
|
285
|
+
if row.source not in added:
|
|
286
|
+
hub = Hub(coords=[getattr(row, k) for k in thisSourceKeys], id=row.source, hubType=hubType)
|
|
287
|
+
self.addHub(hub)
|
|
288
|
+
added.add(row.source)
|
|
289
|
+
|
|
290
|
+
if row.destination not in added:
|
|
291
|
+
hub = Hub(coords=[getattr(row, k) for k in thisDestinationKeys], id=row.destination, hubType=hubType)
|
|
292
|
+
self.addHub(hub)
|
|
293
|
+
added.add(row.destination)
|
|
294
|
+
|
|
295
|
+
# get extra metrics
|
|
296
|
+
extra_metrics = {
|
|
297
|
+
col: getattr(row, col)
|
|
298
|
+
for col in extra_metric_cols
|
|
299
|
+
if hasattr(row, col)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
# link with the extra metrics
|
|
303
|
+
self._addLink(
|
|
304
|
+
hub1=self.Graph[hubType][row.source],
|
|
305
|
+
hub2=self.Graph[hubType][row.destination],
|
|
306
|
+
mode=self.TransportModes[hubType],
|
|
307
|
+
distance=row.distance, # distance metric is absolutely required for all links
|
|
308
|
+
extraData=extra_metrics
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def _hubToHubDistances(self, hub1: list[Hub], hub2: list[Hub]):
|
|
312
|
+
"""
|
|
313
|
+
Compute full pairwise distance matrix between two lists of hubs using Haversine.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
hub1: list of Hub
|
|
317
|
+
hub2: list of Hub
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
numpy.ndarray of shape (len(hub1), len(hub2))
|
|
321
|
+
"""
|
|
322
|
+
import torch
|
|
323
|
+
R = 6371.0
|
|
324
|
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
325
|
+
|
|
326
|
+
with torch.no_grad():
|
|
327
|
+
lat1 = torch.deg2rad(torch.tensor([h.coords[0] for h in hub1], device=device))
|
|
328
|
+
lng1 = torch.deg2rad(torch.tensor([h.coords[1] for h in hub1], device=device))
|
|
329
|
+
lat2 = torch.deg2rad(torch.tensor([h.coords[0] for h in hub2], device=device))
|
|
330
|
+
lng2 = torch.deg2rad(torch.tensor([h.coords[1] for h in hub2], device=device))
|
|
331
|
+
|
|
332
|
+
lat1 = lat1.unsqueeze(1)
|
|
333
|
+
lng1 = lng1.unsqueeze(1)
|
|
334
|
+
lat2 = lat2.unsqueeze(0)
|
|
335
|
+
lng2 = lng2.unsqueeze(0)
|
|
336
|
+
|
|
337
|
+
dlat = lat2 - lat1
|
|
338
|
+
dlng = lng2 - lng1
|
|
339
|
+
|
|
340
|
+
a = torch.sin(dlat / 2) ** 2 + torch.cos(lat1) * torch.cos(lat2) * torch.sin(dlng / 2) ** 2
|
|
341
|
+
c = 2 * torch.atan2(torch.sqrt(a), torch.sqrt(1 - a))
|
|
342
|
+
distances = R * c
|
|
343
|
+
|
|
344
|
+
return distances.cpu().numpy()
|
|
345
|
+
|
|
346
|
+
def _primary_metric(self, optimization_metric):
|
|
347
|
+
"""
|
|
348
|
+
If optimization_metric is a tuple, return the last element of the tuple.
|
|
349
|
+
Otherwise, return optimization_metric itself.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
optimization_metric (OptimizationMetric | str | tuple): The optimization metric to get the primary metric from.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
OptimizationMetric | str: The primary optimization metric.
|
|
356
|
+
"""
|
|
357
|
+
if isinstance(optimization_metric, tuple):
|
|
358
|
+
return optimization_metric[-1]
|
|
359
|
+
return optimization_metric
|
|
360
|
+
|
|
361
|
+
def _build_priority_spec(
|
|
362
|
+
self,
|
|
363
|
+
optimization_metric: OptimizationMetric | str | tuple,
|
|
364
|
+
):
|
|
365
|
+
"""
|
|
366
|
+
preprocesses the optim metric request into the expected tuple format for _dijkstra_single_source
|
|
367
|
+
|
|
368
|
+
:param optimization_metric: the target metrics (in order of importance) or a single metric
|
|
369
|
+
:type optimization_metric: OptimizationMetric | str | tuple
|
|
370
|
+
"""
|
|
371
|
+
# ensure backwards compatibility with single metric optim
|
|
372
|
+
if isinstance(optimization_metric, (OptimizationMetric, str)):
|
|
373
|
+
return (optimization_metric,)
|
|
374
|
+
|
|
375
|
+
# already correct type
|
|
376
|
+
if isinstance(optimization_metric, tuple):
|
|
377
|
+
return optimization_metric
|
|
378
|
+
|
|
379
|
+
# list
|
|
380
|
+
if isinstance(optimization_metric, list):
|
|
381
|
+
return tuple(optimization_metric)
|
|
382
|
+
|
|
383
|
+
raise TypeError("Invalid optimization_metric")
|
|
384
|
+
|
|
385
|
+
def _compute_priority(
|
|
386
|
+
self,
|
|
387
|
+
path: PathNode,
|
|
388
|
+
acc_metrics: EdgeMetadata,
|
|
389
|
+
priority_spec: tuple,
|
|
390
|
+
):
|
|
391
|
+
"""
|
|
392
|
+
collects the values from the current state,
|
|
393
|
+
based on the optim metrics requested
|
|
394
|
+
|
|
395
|
+
:param path: the last node i the current path (has prev)
|
|
396
|
+
:type path: PathNode
|
|
397
|
+
:param acc_metrics: the accumulated metrics
|
|
398
|
+
:type acc_metrics: EdgeMetadata
|
|
399
|
+
:param priority_spec: the optim metrics (in order of importance)
|
|
400
|
+
:type priority_spec: tuple
|
|
401
|
+
"""
|
|
402
|
+
values = []
|
|
403
|
+
|
|
404
|
+
for key in priority_spec:
|
|
405
|
+
if key == "hops":
|
|
406
|
+
values.append(path.length)
|
|
407
|
+
else:
|
|
408
|
+
values.append(acc_metrics.getMetric(key))
|
|
409
|
+
|
|
410
|
+
return tuple(values)
|
|
411
|
+
|
|
412
|
+
def _dijkstra_single_source(
|
|
413
|
+
self,
|
|
414
|
+
start_id: str,
|
|
415
|
+
target_ids: set[str],
|
|
416
|
+
allowed_modes: list[str] | None,
|
|
417
|
+
optimization_metric: OptimizationMetric | tuple,
|
|
418
|
+
max_segments: int,
|
|
419
|
+
custom_filter: Filter | None,
|
|
420
|
+
):
|
|
421
|
+
"""
|
|
422
|
+
implements pareto dijkstra on the graph
|
|
423
|
+
"""
|
|
424
|
+
|
|
425
|
+
counter = count()
|
|
426
|
+
priority_spec = self._build_priority_spec(optimization_metric)
|
|
427
|
+
|
|
428
|
+
def dominates(p1: tuple, p2: tuple) -> bool:
|
|
429
|
+
"""Return True if p1 dominates p2 (<= all, < at least one)."""
|
|
430
|
+
strictly_better = False
|
|
431
|
+
for a, b in zip(p1, p2):
|
|
432
|
+
if a > b:
|
|
433
|
+
return False
|
|
434
|
+
if a < b:
|
|
435
|
+
strictly_better = True
|
|
436
|
+
return strictly_better
|
|
437
|
+
|
|
438
|
+
pq: list[tuple[tuple, int, PathNode, EdgeMetadata]] = []
|
|
439
|
+
|
|
440
|
+
start_metrics = EdgeMetadata(
|
|
441
|
+
transportMode=None,
|
|
442
|
+
**{
|
|
443
|
+
(m if isinstance(m, str) else m.value): 0
|
|
444
|
+
for m in priority_spec
|
|
445
|
+
if m != "hops"
|
|
446
|
+
}
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
start_path = PathNode(
|
|
450
|
+
hub_id=start_id,
|
|
451
|
+
mode="",
|
|
452
|
+
edge=EdgeMetadata(),
|
|
453
|
+
prev=None,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
start_priority = self._compute_priority(
|
|
457
|
+
start_path, start_metrics, priority_spec
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
heapq.heappush(
|
|
461
|
+
pq, (start_priority, next(counter), start_path, start_metrics)
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
labels: dict[str, list[tuple]] = defaultdict(list)
|
|
465
|
+
|
|
466
|
+
results: dict[str, list[tuple[PathNode, EdgeMetadata, tuple]]] = defaultdict(list)
|
|
467
|
+
|
|
468
|
+
if allowed_modes is None:
|
|
469
|
+
allowed_modes = list(self.TransportModes.values())
|
|
470
|
+
if self.drivingEnabled:
|
|
471
|
+
allowed_modes.append("driving")
|
|
472
|
+
|
|
473
|
+
while pq:
|
|
474
|
+
priority, _, path_node, acc_metrics = heapq.heappop(pq)
|
|
475
|
+
hub_id = path_node.hub_id
|
|
476
|
+
path_len = path_node.length
|
|
477
|
+
|
|
478
|
+
# check if this label is already dominated
|
|
479
|
+
if any(dominates(p, priority) for p in labels[hub_id]):
|
|
480
|
+
continue
|
|
481
|
+
|
|
482
|
+
# remove labels dominated by this one
|
|
483
|
+
labels[hub_id] = [
|
|
484
|
+
p for p in labels[hub_id] if not dominates(priority, p)
|
|
485
|
+
]
|
|
486
|
+
labels[hub_id].append(priority)
|
|
487
|
+
|
|
488
|
+
# if this is a target hub add the path to the result
|
|
489
|
+
if hub_id in target_ids:
|
|
490
|
+
results[hub_id].append((path_node, acc_metrics, priority))
|
|
491
|
+
|
|
492
|
+
# early stop if the path is too long
|
|
493
|
+
if path_len >= max_segments:
|
|
494
|
+
continue
|
|
495
|
+
|
|
496
|
+
current_hub = self.getHubById(hub_id)
|
|
497
|
+
if current_hub is None:
|
|
498
|
+
continue
|
|
499
|
+
|
|
500
|
+
# for each outgoing connection
|
|
501
|
+
for mode in allowed_modes:
|
|
502
|
+
# if no edge with this mode exists skip it
|
|
503
|
+
if mode not in current_hub.outgoing:
|
|
504
|
+
continue
|
|
505
|
+
# iter over the connections with this mode
|
|
506
|
+
for next_hub_id, conn_metrics in current_hub.outgoing[mode].items():
|
|
507
|
+
if conn_metrics is None:
|
|
508
|
+
continue
|
|
509
|
+
|
|
510
|
+
# get the hub
|
|
511
|
+
next_hub = self.getHubById(next_hub_id)
|
|
512
|
+
if next_hub is None: # failsave if hub state is invalid
|
|
513
|
+
continue
|
|
514
|
+
|
|
515
|
+
# if present apply the filter
|
|
516
|
+
if custom_filter is not None:
|
|
517
|
+
if not custom_filter.filter(
|
|
518
|
+
current_hub,
|
|
519
|
+
next_hub,
|
|
520
|
+
conn_metrics,
|
|
521
|
+
path_node,
|
|
522
|
+
):
|
|
523
|
+
continue
|
|
524
|
+
|
|
525
|
+
# accumulate metrics
|
|
526
|
+
new_acc_metrics = EdgeMetadata(
|
|
527
|
+
transportMode=None,
|
|
528
|
+
**acc_metrics.metrics,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
for k, v in conn_metrics.metrics.items():
|
|
532
|
+
# accumulate the numeric metrics
|
|
533
|
+
# update the string metrics
|
|
534
|
+
if isinstance(v, (int, float)):
|
|
535
|
+
new_acc_metrics.metrics[k] = (
|
|
536
|
+
new_acc_metrics.metrics.get(k, 0) + v
|
|
537
|
+
)
|
|
538
|
+
else:
|
|
539
|
+
new_acc_metrics.metrics[k] = v
|
|
540
|
+
|
|
541
|
+
# create a new pathnode to save the sub path
|
|
542
|
+
new_path_node = PathNode(
|
|
543
|
+
hub_id=next_hub_id,
|
|
544
|
+
mode=mode,
|
|
545
|
+
edge=conn_metrics,
|
|
546
|
+
prev=path_node,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
# get the priority value for this path
|
|
550
|
+
new_priority = self._compute_priority(
|
|
551
|
+
new_path_node, new_acc_metrics, priority_spec
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
# save to pq
|
|
555
|
+
heapq.heappush(
|
|
556
|
+
pq,
|
|
557
|
+
(
|
|
558
|
+
new_priority,
|
|
559
|
+
next(counter),
|
|
560
|
+
new_path_node,
|
|
561
|
+
new_acc_metrics,
|
|
562
|
+
),
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
# build the final results
|
|
566
|
+
final_results: dict[str, tuple[PathNode, EdgeMetadata]] = {}
|
|
567
|
+
|
|
568
|
+
# collect the best path for each hub
|
|
569
|
+
for hub_id, entries in results.items():
|
|
570
|
+
best = min(entries, key=lambda e: e[2])
|
|
571
|
+
final_results[hub_id] = (best[0], best[1])
|
|
572
|
+
|
|
573
|
+
return final_results
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _build_route(
|
|
577
|
+
self,
|
|
578
|
+
path_node: PathNode,
|
|
579
|
+
acc_metrics: EdgeMetadata,
|
|
580
|
+
optimization_metric: OptimizationMetric,
|
|
581
|
+
verbose: bool,
|
|
582
|
+
) -> Route:
|
|
583
|
+
if verbose:
|
|
584
|
+
path = [
|
|
585
|
+
(n.hub_id, n.mode, n.edge)
|
|
586
|
+
for n in path_node
|
|
587
|
+
]
|
|
588
|
+
else:
|
|
589
|
+
path = [
|
|
590
|
+
(n.hub_id, n.mode)
|
|
591
|
+
for n in path_node
|
|
592
|
+
]
|
|
593
|
+
|
|
594
|
+
return Route(
|
|
595
|
+
path=path,
|
|
596
|
+
totalMetrics=acc_metrics,
|
|
597
|
+
optimizedMetric=optimization_metric,
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
# ============= public key functions =============
|
|
601
|
+
|
|
602
|
+
def build(self):
|
|
603
|
+
self._generateHubs()
|
|
604
|
+
# exit here if not driving edges are allowed
|
|
605
|
+
if not self.drivingEnabled:
|
|
606
|
+
return
|
|
607
|
+
|
|
608
|
+
# build driving edges
|
|
609
|
+
hubTypes = list(self.Graph.keys())
|
|
610
|
+
for i, hubType1 in enumerate(hubTypes):
|
|
611
|
+
hubs1 = list(self.Graph[hubType1].values())
|
|
612
|
+
for _, hubType2 in enumerate(hubTypes[i:], start=i):
|
|
613
|
+
hubs2 = list(self.Graph[hubType2].values())
|
|
614
|
+
distances = self._hubToHubDistances(hubs1, hubs2)
|
|
615
|
+
|
|
616
|
+
for hi, hub1 in enumerate(hubs1):
|
|
617
|
+
for hj, hub2 in enumerate(hubs2):
|
|
618
|
+
if hub1.id == hub2.id:
|
|
619
|
+
continue
|
|
620
|
+
|
|
621
|
+
d = distances[hi, hj]
|
|
622
|
+
if d <= self.maxDrivingDistance:
|
|
623
|
+
self._addLink(
|
|
624
|
+
hub1=hub1,
|
|
625
|
+
hub2=hub2,
|
|
626
|
+
mode="car", # explicitly set driving
|
|
627
|
+
distance=d,
|
|
628
|
+
bidirectional=True,
|
|
629
|
+
# no extra metrics for default drive nodes
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
def find_shortest_path(
|
|
633
|
+
self,
|
|
634
|
+
start_id: str,
|
|
635
|
+
end_id: str,
|
|
636
|
+
allowed_modes: list[str] | None = None,
|
|
637
|
+
optimization_metric: OptimizationMetric | str | tuple = OptimizationMetric.DISTANCE,
|
|
638
|
+
max_segments: int = 10,
|
|
639
|
+
verbose: bool = False,
|
|
640
|
+
custom_filter: Filter | None = None,
|
|
641
|
+
) -> Route | VerboseRoute | None:
|
|
642
|
+
if not isinstance(end_id, str):
|
|
643
|
+
raise TypeError("end_id must be a single hub id (str)")
|
|
644
|
+
|
|
645
|
+
if allowed_modes is None:
|
|
646
|
+
allowed_modes = list(self.TransportModes.values())
|
|
647
|
+
|
|
648
|
+
results = self._dijkstra_single_source(
|
|
649
|
+
start_id=start_id,
|
|
650
|
+
target_ids={end_id},
|
|
651
|
+
allowed_modes=allowed_modes,
|
|
652
|
+
optimization_metric=optimization_metric,
|
|
653
|
+
max_segments=max_segments,
|
|
654
|
+
custom_filter=custom_filter,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
if end_id not in results:
|
|
658
|
+
return None
|
|
659
|
+
|
|
660
|
+
path_node, acc_metrics = results[end_id]
|
|
661
|
+
|
|
662
|
+
return self._build_route(
|
|
663
|
+
path_node,
|
|
664
|
+
acc_metrics,
|
|
665
|
+
self._primary_metric(optimization_metric),
|
|
666
|
+
verbose,
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
def find_shortest_paths(
|
|
670
|
+
self,
|
|
671
|
+
start_id: str,
|
|
672
|
+
end_ids: list[str],
|
|
673
|
+
allowed_modes: list[str] | None = None,
|
|
674
|
+
optimization_metric: OptimizationMetric | str | tuple = OptimizationMetric.DISTANCE,
|
|
675
|
+
max_segments: int = 10,
|
|
676
|
+
verbose: bool = False,
|
|
677
|
+
custom_filter: Filter | None = None,
|
|
678
|
+
) -> dict[str, Route | VerboseRoute]:
|
|
679
|
+
if not end_ids:
|
|
680
|
+
return {}
|
|
681
|
+
|
|
682
|
+
if allowed_modes is None:
|
|
683
|
+
allowed_modes = list(self.TransportModes.values())
|
|
684
|
+
|
|
685
|
+
target_ids = set(end_ids)
|
|
686
|
+
|
|
687
|
+
results = self._dijkstra_single_source(
|
|
688
|
+
start_id=start_id,
|
|
689
|
+
target_ids=target_ids,
|
|
690
|
+
allowed_modes=allowed_modes,
|
|
691
|
+
optimization_metric=optimization_metric,
|
|
692
|
+
max_segments=max_segments,
|
|
693
|
+
custom_filter=custom_filter,
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
routes: dict[str, Route | VerboseRoute] = {}
|
|
697
|
+
|
|
698
|
+
primary_metric = self._primary_metric(optimization_metric)
|
|
699
|
+
|
|
700
|
+
for dst, (path_node, acc_metrics) in results.items():
|
|
701
|
+
routes[dst] = self._build_route(
|
|
702
|
+
path_node,
|
|
703
|
+
acc_metrics,
|
|
704
|
+
primary_metric,
|
|
705
|
+
verbose,
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
return routes
|
|
709
|
+
|
|
710
|
+
def fully_connect_points(self, point_ids: list[str], **kwargs):
|
|
711
|
+
graph = {}
|
|
712
|
+
for start in point_ids:
|
|
713
|
+
targets = [p for p in point_ids if p != start]
|
|
714
|
+
graph[start] = self.find_shortest_paths(start, targets, **kwargs)
|
|
715
|
+
return graph
|
|
716
|
+
|
|
717
|
+
def radial_search(
|
|
718
|
+
self,
|
|
719
|
+
hub_id: str,
|
|
720
|
+
radius: float,
|
|
721
|
+
optimization_metric: OptimizationMetric | str = OptimizationMetric.DISTANCE,
|
|
722
|
+
allowed_modes: list[str] = None,
|
|
723
|
+
custom_filter: Filter = None,
|
|
724
|
+
) -> list[float, Hub]:
|
|
725
|
+
"""
|
|
726
|
+
Find all hubs within a given radius of a given hub
|
|
727
|
+
(Note: distance is measured from the connecting paths not direct)
|
|
728
|
+
|
|
729
|
+
Args:
|
|
730
|
+
hub_id: ID of the center hub
|
|
731
|
+
radius: maximum distance from the center hub
|
|
732
|
+
optimization_metric: metric to optimize for (e.g. distance, time, cost)
|
|
733
|
+
allowed_modes: list of allowed transport modes (default: None => all modes)
|
|
734
|
+
|
|
735
|
+
Returns:
|
|
736
|
+
list of tuples containing the metric value and the corresponding hub object
|
|
737
|
+
"""
|
|
738
|
+
|
|
739
|
+
center = self.getHubById(hub_id)
|
|
740
|
+
if center is None:
|
|
741
|
+
return [center]
|
|
742
|
+
|
|
743
|
+
if allowed_modes is None:
|
|
744
|
+
allowed_modes = list(self.TransportModes.values())
|
|
745
|
+
|
|
746
|
+
hubsToSearch = deque([center])
|
|
747
|
+
queued = set([hub_id])
|
|
748
|
+
reachableHubs: dict[str, tuple[float, Hub]] = {hub_id: (0.0, center)}
|
|
749
|
+
|
|
750
|
+
while hubsToSearch:
|
|
751
|
+
hub = hubsToSearch.popleft() # get the current hub to search
|
|
752
|
+
currentMetricVal, _ = reachableHubs[hub.id] # get the current metric value
|
|
753
|
+
for mode in allowed_modes:
|
|
754
|
+
outgoing = hub.outgoing.get(mode, {}) # find all outgoing connections
|
|
755
|
+
# dict like {dest_id: EdgeMetadata}
|
|
756
|
+
for id, edgemetadata in outgoing.items(): # iter over outgoing connections
|
|
757
|
+
thisMetricVal = edgemetadata.getMetric(optimization_metric)
|
|
758
|
+
if thisMetricVal is None:
|
|
759
|
+
continue
|
|
760
|
+
nextMetricVal = currentMetricVal + thisMetricVal
|
|
761
|
+
if nextMetricVal > radius:
|
|
762
|
+
continue
|
|
763
|
+
knownMetric = reachableHubs.get(id, None)
|
|
764
|
+
destHub = self.getHubById(id)
|
|
765
|
+
if custom_filter is not None and not custom_filter.filter(hub, destHub, edgemetadata):
|
|
766
|
+
continue
|
|
767
|
+
# only save smaller metric values
|
|
768
|
+
if knownMetric is None or knownMetric[0] > nextMetricVal:
|
|
769
|
+
reachableHubs.update({id: (nextMetricVal, destHub)})
|
|
770
|
+
if id not in queued:
|
|
771
|
+
queued.add(id)
|
|
772
|
+
hubsToSearch.append(destHub)
|
|
773
|
+
|
|
774
|
+
return [v for v in reachableHubs.values()]
|
|
775
|
+
|
|
776
|
+
def compare_routes(
|
|
777
|
+
self,
|
|
778
|
+
start_id: str,
|
|
779
|
+
end_id: str,
|
|
780
|
+
allowed_modes: list[str],
|
|
781
|
+
metrics_to_compare: list[OptimizationMetric] = None
|
|
782
|
+
) -> dict[OptimizationMetric, Route]:
|
|
783
|
+
"""
|
|
784
|
+
Find optimal routes for different metrics and compare them
|
|
785
|
+
|
|
786
|
+
Returns:
|
|
787
|
+
Dictionary mapping each optimization metric to its optimal route
|
|
788
|
+
"""
|
|
789
|
+
if metrics_to_compare is None:
|
|
790
|
+
metrics_to_compare = list(OptimizationMetric)
|
|
791
|
+
|
|
792
|
+
results = {}
|
|
793
|
+
for metric in metrics_to_compare:
|
|
794
|
+
route = self.find_shortest_path(start_id, end_id, optimization_metric=metric, allowed_modes=allowed_modes)
|
|
795
|
+
if route:
|
|
796
|
+
results[metric] = route
|
|
797
|
+
|
|
798
|
+
return results
|