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