multimodalrouter 0.1.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.

Potentially problematic release.


This version of multimodalrouter might be problematic. Click here for more details.

@@ -0,0 +1,4 @@
1
+ from .graph import RouteGraph, Hub, EdgeMetadata, OptimizationMetric, Route, VerboseRoute
2
+ from .utils import preprocessor
3
+
4
+ __all__ = ["RouteGraph", "Hub", "EdgeMetadata", "OptimizationMetric", "Route", "VerboseRoute", "preprocessor"]
@@ -0,0 +1,2 @@
1
+ from .graph import RouteGraph # noqa: F401
2
+ from .dataclasses import Hub, EdgeMetadata, OptimizationMetric, Route, VerboseRoute # noqa: F401
@@ -0,0 +1,106 @@
1
+ # dataclasses.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 dataclasses import dataclass
7
+ from enum import Enum
8
+
9
+
10
+ class OptimizationMetric(Enum):
11
+ DISTANCE = "distance"
12
+
13
+
14
+ class EdgeMetadata:
15
+ __slots__ = ['transportMode', 'metrics']
16
+
17
+ def __init__(self, transportMode: str = None, **metrics):
18
+ self.transportMode = transportMode
19
+ self.metrics = metrics # e.g., {"distance": 12.3, "time": 15} NOTE distance is required by the graph
20
+
21
+ def getMetric(self, metric: OptimizationMetric | str):
22
+ if isinstance(metric, str):
23
+ value = self.metrics.get(metric)
24
+ if value is None:
25
+ raise KeyError(f"Metric '{metric}' not found in EdgeMetadata")
26
+ return value
27
+
28
+ value = self.metrics.get(metric.value)
29
+ if value is None:
30
+ raise KeyError(f"Metric '{metric.value}' not found in EdgeMetadata")
31
+ return value
32
+
33
+ def copy(self):
34
+ return EdgeMetadata(transportMode=self.transportMode, **self.metrics)
35
+
36
+ @property
37
+ def allMetrics(self):
38
+ return self.metrics.copy()
39
+
40
+ def __str__(self):
41
+ return f"transportMode={self.transportMode}, metrics={self.metrics}"
42
+
43
+
44
+ class Hub:
45
+ """Base hub class - using regular class instead of dataclass for __slots__ compatibility"""
46
+ __slots__ = ['coords', 'id', 'outgoing', 'hubType']
47
+
48
+ def __init__(self, coords: list[float], id: str, hubType: str):
49
+ self.coords: list[float] = coords
50
+ self.id = id
51
+ self.hubType = hubType
52
+ self.outgoing = {}
53
+
54
+ def addOutgoing(self, mode: str, dest_id: str, metrics: EdgeMetadata):
55
+ if mode not in self.outgoing:
56
+ self.outgoing[mode] = {}
57
+ self.outgoing[mode][dest_id] = metrics
58
+
59
+ def getMetrics(self, mode: str, dest_id: str) -> EdgeMetadata:
60
+ return self.outgoing.get(mode, {}).get(dest_id, None)
61
+
62
+ def getMetric(self, mode: str, dest_id: str, metric: str) -> float:
63
+ connection = self.outgoing.get(mode, {}).get(dest_id)
64
+ return getattr(connection, metric, None) if connection else None
65
+
66
+ def __hash__(self):
67
+ return hash((self.hubType, self.id))
68
+
69
+
70
+ @dataclass
71
+ class Route:
72
+ """Route class can use dataclass since it doesn't need __slots__"""
73
+ path: list[tuple[str, str]]
74
+ totalMetrics: EdgeMetadata
75
+ optimizedMetric: OptimizationMetric
76
+
77
+ @property
78
+ def optimizedValue(self):
79
+ return self.totalMetrics.getMetric(self.optimizedMetric)
80
+
81
+ @property
82
+ def flatPath(self, toStr=True):
83
+ """Flatten the path into a list of hub IDs"""
84
+ if not self.path:
85
+ return []
86
+ # get all source hubs plus the final destination
87
+ path = [edge for edge in self.path]
88
+ if not toStr:
89
+ return path
90
+ pathStr = ""
91
+ for i, edge in enumerate(path):
92
+ if i == 0:
93
+ pathStr += f"Start: {edge[0]}"
94
+ continue
95
+
96
+ if len(edge) > 2 and isinstance(edge[2], EdgeMetadata):
97
+ pathStr += f"\n\tEdge: ({str(edge[2])})\n-> {edge[0]}"
98
+ else:
99
+ pathStr += f"{edge[0]} -> {edge[1]}"
100
+ return pathStr
101
+
102
+
103
+ @dataclass
104
+ class VerboseRoute(Route):
105
+ """Uses base Route class but adds additional info to hold the edge metadata for every leg"""
106
+ path: list[tuple[str, str, EdgeMetadata]]
@@ -0,0 +1,513 @@
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
13
+ from threading import Lock
14
+
15
+
16
+ class RouteGraph:
17
+
18
+ def __init__(
19
+ self,
20
+ # sets the max distance to connect hubs with driving edges
21
+ maxDistance: float,
22
+ # dict like {hubtype -> "airport": "fly", "shippingport": "shipping"}
23
+ transportModes: dict[str, str],
24
+ # dict like {hubtype -> "airport": path -> "airports.csv", "shippingport": "shippingports.csv"}
25
+ dataPaths: dict[str, str] = {},
26
+ # if true model file will be compressed otherwise normal .dill file
27
+ compressed: bool = False,
28
+ # list of extra columns to add to the edge metadata (dynamically added to links when key is present in dataser)
29
+ extraMetricsKeys: list[str] = [],
30
+ # if true will connect hubs with driving edges
31
+ drivingEnabled: bool = True,
32
+ # a list of coordinate names for the source coords in the datasets (name to dataset matching is automatic)
33
+ sourceCoordKeys: list[str] = ["source_lat", "source_lng"],
34
+ # a list of coordinate names for the destination coords in the datasets (name to dataset matching is automatic)
35
+ destCoordKeys: list[str] = ["destination_lat", "destination_lng"],
36
+ ):
37
+ self.sourceCoordKeys = set(sourceCoordKeys)
38
+ self.destCoordKeys = set(destCoordKeys)
39
+
40
+ self.compressed = compressed
41
+ self.extraMetricsKeys = extraMetricsKeys
42
+ self.drivingEnabled = drivingEnabled
43
+
44
+ self.TransportModes = transportModes
45
+ # hubtype -> {hubid -> Hub}
46
+ self.Graph: dict[str, dict[str, Hub]] = {}
47
+
48
+ # save the paths to the data in the state dict
49
+ for key, value in dataPaths.items():
50
+ setattr(self, key + "DataPath", value)
51
+ self.Graph[key] = {}
52
+
53
+ self.maxDrivingDistance = maxDistance
54
+
55
+ self._lock = Lock()
56
+
57
+ def __getstate__(self):
58
+ state = self.__dict__.copy()
59
+
60
+ # remove attributes that break pickle
61
+ if "_lock" in state:
62
+ del state["_lock"]
63
+
64
+ return state
65
+
66
+ def __setstate__(self, state):
67
+ self.__dict__.update(state)
68
+ # set the lock for thread safety
69
+ from threading import Lock
70
+ self._lock = Lock()
71
+
72
+ # =========== public helpers ==========
73
+
74
+ def findClosestHub(self, allowedHubTypes: list[str], coords: list[float]) -> Hub | None:
75
+ """
76
+ Find the closest hub of a given type to a given location.
77
+
78
+ Args:
79
+ hubType: Type of hub to find
80
+ coords: list[float] = the coordinates of the location
81
+
82
+ Returns:
83
+ Hub instance if found, None otherwise
84
+ """
85
+ potentialHubs = []
86
+ if allowedHubTypes is not None:
87
+ for hubType in allowedHubTypes:
88
+ potentialHubs.extend(self.Graph.get(hubType, {}).values())
89
+
90
+ if allowedHubTypes is None:
91
+ potentialHubs = list(self._allHubs())
92
+
93
+ if not potentialHubs:
94
+ return None
95
+
96
+ tempHub = Hub(coords=coords, hubType="temp", id="temp")
97
+ distances = self._hubToHubDistances([tempHub], potentialHubs).flatten() # shape (n,)
98
+ closest_hub = potentialHubs[distances.argmin()]
99
+ return closest_hub
100
+
101
+ def addHub(self, hub: Hub):
102
+ """
103
+ Add a hub to the graph. If the hub already exists, it will not be added a second time.
104
+
105
+ Args:
106
+ hub: Hub instance to add to the graph
107
+
108
+ Returns:
109
+ None
110
+ """
111
+ with self._lock:
112
+ hubType = hub.hubType
113
+ # if the hub type doesnt exist in the graph, create it first
114
+ if hubType not in self.Graph:
115
+ self.Graph[hubType] = {}
116
+ # exit if the hub already exists
117
+ if hub.id in self.Graph[hubType]:
118
+ return
119
+ # add the hub
120
+ self.Graph[hubType][hub.id] = hub
121
+
122
+ def getHub(self, hubType: str, id: str) -> Hub | None:
123
+ """
124
+ Get a hub from the graph by hub type and hub id.
125
+
126
+ Args:
127
+ hubType: Type of hub to get
128
+ id: ID of the hub to get
129
+
130
+ Returns:
131
+ Hub instance if found, None otherwise
132
+ """
133
+ return self.Graph.get(hubType, {}).get(id)
134
+
135
+ def getHubById(self, id: str) -> Hub | None:
136
+ """
137
+ Get a hub from the graph by its ID.
138
+
139
+ Args:
140
+ id: ID of the hub to get
141
+
142
+ Returns:
143
+ Hub instance if found, None otherwise
144
+ """
145
+ for hubType in self.Graph:
146
+ hub = self.Graph[hubType].get(id)
147
+ if hub:
148
+ return hub
149
+ return None
150
+
151
+ def save(
152
+ self,
153
+ filepath: str = os.path.join(os.getcwd(), "..", "..", "..", "data"),
154
+ compressed: bool = False
155
+ ):
156
+ """
157
+ Save the RouteGraph to a file.
158
+
159
+ Args:
160
+ filepath (str): Path to save the graph to.
161
+ saveMode (str, optional): Unused. Defaults to None.
162
+ compressed (bool, optional): Whether to compress the saved graph. Defaults to False.
163
+
164
+ Note: The graph is saved in the following format:
165
+ - Compressed: <filepath>.zlib
166
+ - Uncompressed: <filepath>.dill
167
+ """
168
+ with self._lock:
169
+
170
+ # ensure correct compression type is set for loading the graph
171
+ self.compressed = compressed
172
+ os.makedirs(filepath, exist_ok=True)
173
+ # save the graph
174
+ pickled = dill.dumps(self)
175
+ if compressed:
176
+ compressed = zlib.compress(pickled)
177
+ with open(os.path.join(filepath, "graph.zlib"), "wb") as f:
178
+ f.write(compressed)
179
+ else:
180
+ with open(os.path.join(filepath, "graph.dill"), "wb") as f:
181
+ f.write(pickled)
182
+
183
+ @staticmethod
184
+ def load(filepath: str, compressed: bool = False) -> "RouteGraph":
185
+ """
186
+ Load a RouteGraph from a file.
187
+
188
+ Args:
189
+ filepath (str): Path to load the graph from.
190
+ compressed (bool, optional): Whether the saved graph is compressed. Defaults to False.
191
+
192
+ Returns:
193
+ RouteGraph: The loaded graph.
194
+ """
195
+ with open(filepath, "rb") as f:
196
+ file_data = f.read()
197
+ if compressed:
198
+ decompressed = zlib.decompress(file_data)
199
+ graph = dill.loads(decompressed)
200
+ else:
201
+ graph = dill.loads(file_data)
202
+ return graph
203
+
204
+ # ============= private helpers =============
205
+
206
+ def _allHubs(self):
207
+ for hubType in self.Graph:
208
+ yield from self.Graph[hubType].values()
209
+
210
+ def _addLink(
211
+ self,
212
+ hub1: Hub,
213
+ hub2: Hub,
214
+ mode: str,
215
+ distance: float,
216
+ bidirectional: bool = False,
217
+ extraData: dict | None = None
218
+ ):
219
+ """
220
+ Add a connection between two hubs, dynamically storing extra metrics.
221
+ """
222
+ with self._lock:
223
+ if extraData is None:
224
+ extraData = {}
225
+ # combine required metrics with extra
226
+ metrics = {"distance": distance, **extraData}
227
+ edge = EdgeMetadata(transportMode=mode, **metrics)
228
+ hub1.addOutgoing(mode, hub2.id, edge)
229
+ if bidirectional:
230
+ hub2.addOutgoing(mode, hub1.id, edge.copy())
231
+
232
+ def _loadData(self, targetHubType: str):
233
+ dataPath = getattr(self, targetHubType + "DataPath")
234
+ if dataPath is None:
235
+ raise ValueError(f"Data path for {targetHubType} is not set")
236
+ fType = os.path.splitext(dataPath)[1]
237
+ if fType == ".csv":
238
+ data = pd.read_csv(dataPath)
239
+ elif fType == ".parquet":
240
+ data = pd.read_parquet(dataPath)
241
+ else:
242
+ raise ValueError(f"Unsupported file type {fType}")
243
+
244
+ if data.empty:
245
+ raise ValueError(f"{targetHubType} data is empty load the data first")
246
+
247
+ return data
248
+
249
+ def _generateHubs(self):
250
+ """
251
+ Generate Hub instances and link them with EdgeMetadata.
252
+ Extra columns in the data will be added to EdgeMetadata dynamically.
253
+ """
254
+ # no lock needed since underlying methods have locks
255
+ for hubType in self.Graph.keys():
256
+ data = self._loadData(hubType)
257
+ added = set()
258
+
259
+ thisSourceKeys = self.sourceCoordKeys & set(data.columns)
260
+ thisDestinationKeys = self.destCoordKeys & set(data.columns)
261
+
262
+ # get required and extra columns
263
+ required_cols = {
264
+ "source", "destination",
265
+ *thisSourceKeys,
266
+ *thisDestinationKeys,
267
+ "distance"
268
+ }
269
+
270
+ # collect extra data from the dataset columns that are not required but marked as extra
271
+ extra_metric_cols = []
272
+ for m in self.extraMetricsKeys:
273
+ if m not in required_cols:
274
+ try:
275
+ extra_metric_cols.append(m)
276
+ except KeyError:
277
+ continue
278
+
279
+ for row in tqdm(data.itertuples(index=False), desc=f"Generating {hubType} Hubs", unit="hub"):
280
+ # create hubs if they don't exist
281
+ if row.source not in added:
282
+ hub = Hub(coords=[getattr(row, k) for k in thisSourceKeys], id=row.source, hubType=hubType)
283
+ self.addHub(hub)
284
+ added.add(row.source)
285
+
286
+ if row.destination not in added:
287
+ hub = Hub(coords=[getattr(row, k) for k in thisDestinationKeys], id=row.destination, hubType=hubType)
288
+ self.addHub(hub)
289
+ added.add(row.destination)
290
+
291
+ # get extra metrics
292
+ extra_metrics = {col: getattr(row, col) for col in extra_metric_cols}
293
+
294
+ # link with the extra metrics
295
+ self._addLink(
296
+ hub1=self.Graph[hubType][row.source],
297
+ hub2=self.Graph[hubType][row.destination],
298
+ mode=self.TransportModes[hubType],
299
+ distance=row.distance, # distance metric is absolutely required for all links
300
+ extraData=extra_metrics
301
+ )
302
+
303
+ def _hubToHubDistances(self, hub1: list[Hub], hub2: list[Hub]):
304
+ """
305
+ Compute full pairwise distance matrix between two lists of hubs using Haversine.
306
+
307
+ Args:
308
+ hub1: list of Hub
309
+ hub2: list of Hub
310
+
311
+ Returns:
312
+ numpy.ndarray of shape (len(hub1), len(hub2))
313
+ """
314
+ import torch
315
+ R = 6371.0
316
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
317
+
318
+ with torch.no_grad():
319
+ lat1 = torch.deg2rad(torch.tensor([h.coords[0] for h in hub1], device=device))
320
+ lng1 = torch.deg2rad(torch.tensor([h.coords[1] for h in hub1], device=device))
321
+ lat2 = torch.deg2rad(torch.tensor([h.coords[0] for h in hub2], device=device))
322
+ lng2 = torch.deg2rad(torch.tensor([h.coords[1] for h in hub2], device=device))
323
+
324
+ lat1 = lat1.unsqueeze(1)
325
+ lng1 = lng1.unsqueeze(1)
326
+ lat2 = lat2.unsqueeze(0)
327
+ lng2 = lng2.unsqueeze(0)
328
+
329
+ dlat = lat2 - lat1
330
+ dlng = lng2 - lng1
331
+
332
+ a = torch.sin(dlat / 2) ** 2 + torch.cos(lat1) * torch.cos(lat2) * torch.sin(dlng / 2) ** 2
333
+ c = 2 * torch.atan2(torch.sqrt(a), torch.sqrt(1 - a))
334
+ distances = R * c
335
+
336
+ return distances.cpu().numpy()
337
+
338
+ # ============= public key functions =============
339
+
340
+ def build(self):
341
+ self._generateHubs()
342
+ # exit here if not driving edges are allowed
343
+ if not self.drivingEnabled:
344
+ return
345
+
346
+ # build driving edges
347
+ hubTypes = list(self.Graph.keys())
348
+ for i, hubType1 in enumerate(hubTypes):
349
+ hubs1 = list(self.Graph[hubType1].values())
350
+ for _, hubType2 in enumerate(hubTypes[i:], start=i):
351
+ hubs2 = list(self.Graph[hubType2].values())
352
+ distances = self._hubToHubDistances(hubs1, hubs2)
353
+
354
+ for hi, hub1 in enumerate(hubs1):
355
+ for hj, hub2 in enumerate(hubs2):
356
+ if hub1.id == hub2.id:
357
+ continue
358
+
359
+ d = distances[hi, hj]
360
+ if d <= self.maxDrivingDistance:
361
+ self._addLink(
362
+ hub1=hub1,
363
+ hub2=hub2,
364
+ mode="car", # explicitly set driving
365
+ distance=d,
366
+ bidirectional=True,
367
+ # no extra metrics for default drive nodes
368
+ )
369
+
370
+ def find_shortest_path(
371
+ self,
372
+ start_id: str,
373
+ end_id: str,
374
+ allowed_modes: list[str],
375
+ optimization_metric: OptimizationMetric | str = OptimizationMetric.DISTANCE,
376
+ max_segments: int = 10,
377
+ verbose: bool = False
378
+ ) -> Route | None:
379
+ """
380
+ Find the optimal path between two hubs using Dijkstra
381
+
382
+ Args:
383
+ start_id: ID of the starting hub
384
+ end_id: ID of the destination hub
385
+ optimization_metric: Metric to optimize for (distance, time, cost, etc.) (must exist in EdgeMetadata)
386
+ allowed_modes: List of allowed transport modes (default: all modes)
387
+ max_segments: Maximum number of segments allowed in route
388
+
389
+ Returns:
390
+ Route object with the optimal path, or None if no path exists
391
+ """
392
+
393
+ # check if start and end hub exist
394
+ start_hub = self.getHubById(start_id)
395
+ end_hub = self.getHubById(end_id)
396
+
397
+ if start_hub is None:
398
+ raise ValueError(f"Start hub '{start_id}' not found in graph")
399
+ if end_hub is None:
400
+ raise ValueError(f"End hub '{end_id}' not found in graph")
401
+
402
+ if start_id == end_id:
403
+ # create a route with only the start hub
404
+ # no verbose since no edges are needed
405
+ return Route(
406
+ path=[(start_id, "")],
407
+ totalMetrics=EdgeMetadata(),
408
+ optimizedMetric=optimization_metric,
409
+ )
410
+
411
+ if verbose:
412
+ # priority queue: (metric_value, hub_id, path_with_modes, accumulated_metrics)
413
+ pq = [(0.0, start_id, [(start_id, "", EdgeMetadata())], EdgeMetadata())]
414
+ else:
415
+ # priority queue: (metric_value, hub_id, path_with_modes, accumulated_metrics)
416
+ pq = [(0.0, start_id, [(start_id, "")], EdgeMetadata())]
417
+
418
+ visited = {} # dict like {hub_id : metric_value}
419
+
420
+ while pq:
421
+ # get the current path data
422
+ # optim metric, hub id, path with modes, accumulated metrics (edgeMetadata object)
423
+ current_metric_value, current_hub_id, path_with_modes, accumulated_metrics = heapq.heappop(pq)
424
+
425
+ # skip this if a better path exists
426
+ if current_hub_id in visited and visited[current_hub_id] <= current_metric_value:
427
+ continue
428
+ # mark as visited
429
+ visited[current_hub_id] = current_metric_value
430
+
431
+ # check if this is the end hub
432
+ if current_hub_id == end_id:
433
+ if verbose:
434
+ return Route(
435
+ path=path_with_modes,
436
+ totalMetrics=accumulated_metrics,
437
+ optimizedMetric=optimization_metric,
438
+ )
439
+
440
+ return Route(
441
+ path=path_with_modes,
442
+ totalMetrics=accumulated_metrics,
443
+ optimizedMetric=optimization_metric,
444
+ )
445
+
446
+ # skip if too many segments
447
+ if len(path_with_modes) > max_segments:
448
+ continue
449
+
450
+ # get the current hub
451
+ current_hub = self.getHubById(current_hub_id)
452
+ if current_hub is None:
453
+ continue
454
+
455
+ # test all outgoing connections from the current hub
456
+ for mode in allowed_modes: # iter over the allowed transport modes
457
+ if mode in current_hub.outgoing: # check if the mode has outgoing connections
458
+ # iter over all outgoing links with the selected transport type
459
+ for next_hub_id, connection_metrics in current_hub.outgoing[mode].items():
460
+ if connection_metrics is None: # skip if the connection has no metrics
461
+ continue
462
+
463
+ # get the selected metric alue for this connection
464
+ connection_value = connection_metrics.getMetric(optimization_metric)
465
+ new_metric_value = current_metric_value + connection_value
466
+
467
+ # skip if a better hub to get here exists
468
+ if next_hub_id in visited and visited[next_hub_id] <= new_metric_value:
469
+ continue
470
+
471
+ # create a new edge obj for the combined metrics | None bc modes my change between edges
472
+ new_accumulated_metrics = EdgeMetadata(transportMode=None, **accumulated_metrics.metrics)
473
+ # accumulate metrics
474
+ for metric_name, metric_value in connection_metrics.metrics.items():
475
+ if isinstance(metric_value, (int, float)):
476
+ new_accumulated_metrics.metrics[metric_name] = new_accumulated_metrics.metrics.get(metric_name, 0) + metric_value # noqa: E501
477
+ else:
478
+ # ignore non-numeric metrics for accumulation (maybe combine strings here)
479
+ new_accumulated_metrics.metrics[metric_name] = metric_value
480
+
481
+ # combine to form a new path
482
+ if verbose:
483
+ new_path = path_with_modes + [(next_hub_id, mode, connection_metrics)]
484
+ else:
485
+ new_path = path_with_modes + [(next_hub_id, mode)]
486
+ # push to the priority queue for future exploration
487
+ heapq.heappush(pq, (new_metric_value, next_hub_id, new_path, new_accumulated_metrics))
488
+
489
+ return None
490
+
491
+ def compare_routes(
492
+ self,
493
+ start_id: str,
494
+ end_id: str,
495
+ allowed_modes: list[str],
496
+ metrics_to_compare: list[OptimizationMetric] = None
497
+ ) -> dict[OptimizationMetric, Route]:
498
+ """
499
+ Find optimal routes for different metrics and compare them
500
+
501
+ Returns:
502
+ Dictionary mapping each optimization metric to its optimal route
503
+ """
504
+ if metrics_to_compare is None:
505
+ metrics_to_compare = list(OptimizationMetric)
506
+
507
+ results = {}
508
+ for metric in metrics_to_compare:
509
+ route = self.find_shortest_path(start_id, end_id, optimization_metric=metric, allowed_modes=allowed_modes)
510
+ if route:
511
+ results[metric] = route
512
+
513
+ return results
File without changes
@@ -0,0 +1,97 @@
1
+ # build.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 ..graph import RouteGraph
7
+ import argparse
8
+ import os
9
+
10
+
11
+ def main():
12
+ print("Building graph...")
13
+ parser = argparse.ArgumentParser(
14
+ description="Collect key-value1-value2 triplets into two dicts"
15
+ )
16
+ parser.add_argument(
17
+ "data",
18
+ nargs="+",
19
+ help="Arguments in groups of 3: hubType transportMode dataPath"
20
+ )
21
+ parser.add_argument(
22
+ "--maxDist",
23
+ type=int,
24
+ default=50,
25
+ help="Maximum distance to connect hubs with driving edges"
26
+ )
27
+ parser.add_argument(
28
+ "--compressed",
29
+ action="store_true",
30
+ help="Whether to compress the saved graph (default: False)"
31
+ )
32
+
33
+ parser.add_argument(
34
+ "--extraMetrics",
35
+ nargs="+",
36
+ default=[],
37
+ help="Extra metrics to add to the edge metadata"
38
+ )
39
+
40
+ parser.add_argument(
41
+ "--drivingEnabled",
42
+ action="store_true",
43
+ default=True,
44
+ help="Whether to connect hubs with driving edges (default: True)"
45
+ )
46
+ path = os.path.dirname(os.path.abspath(__file__))
47
+ parser.add_argument(
48
+ "--Dir",
49
+ type=str,
50
+ default=os.path.join(path, "..", "..", "..", "data"),
51
+ help="Directory to save the graph in (default: .)"
52
+ )
53
+ parser.add_argument(
54
+ "--sourceKeys",
55
+ nargs="+",
56
+ default=["source_lat", "source_lng"],
57
+ help="Source keys to search the source coordinates for (default: ['source_lat', 'source_lng'])"
58
+ )
59
+ parser.add_argument(
60
+ "--destKeys",
61
+ nargs="+",
62
+ default=["destination_lat", "destination_lng"],
63
+ help="Destination keys to search the destination coordinates for (default: ['destination_lat', 'destination_lng'])"
64
+ )
65
+
66
+ args = parser.parse_args()
67
+
68
+ if len(args.data) % 3 != 0:
69
+ parser.error("Arguments must be in groups of 3: hubType transportMode dataPath")
70
+
71
+ transportModes = {}
72
+ dataPaths = {}
73
+
74
+ for i in range(0, len(args.data), 3):
75
+ key, val1, val2 = args.data[i], args.data[i + 1], args.data[i + 2]
76
+ transportModes[key] = val1
77
+ dataPaths[key] = val2
78
+
79
+ graph = RouteGraph(
80
+ maxDistance=args.maxDist,
81
+ transportModes=transportModes,
82
+ dataPaths=dataPaths,
83
+ compressed=args.compressed,
84
+ extraMetricsKeys=args.extraMetrics,
85
+ drivingEnabled=args.drivingEnabled,
86
+ sourceCoordKeys=args.sourceKeys,
87
+ destCoordKeys=args.destKeys
88
+ )
89
+
90
+ graph.build()
91
+ graph.save(filepath=args.Dir, compressed=args.compressed)
92
+
93
+ print("Graph built and saved.")
94
+
95
+
96
+ if __name__ == "__main__":
97
+ main()
@@ -0,0 +1,71 @@
1
+ # router.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 ..graph import RouteGraph
7
+ import argparse
8
+ import os
9
+
10
+
11
+ def main():
12
+ graph = RouteGraph.load(
13
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "data", "graph.dill"),
14
+ compressed=False
15
+ )
16
+
17
+ parser = argparse.ArgumentParser(
18
+ description="parse the arguments"
19
+ )
20
+ parser.add_argument(
21
+ "--start",
22
+ nargs="+",
23
+ type=float,
24
+ required=True,
25
+ help="Start coordinates"
26
+ )
27
+ parser.add_argument(
28
+ "--end",
29
+ nargs="+",
30
+ type=float,
31
+ required=True,
32
+ help="End coordinates"
33
+ )
34
+ parser.add_argument(
35
+ "--allowedModes",
36
+ nargs="+",
37
+ type=str,
38
+ default=["car"],
39
+ help="Allowed transport modes"
40
+ )
41
+ parser.add_argument(
42
+ "--maxSegments",
43
+ type=int,
44
+ default=10,
45
+ help="Maximum number of segments in the route"
46
+ )
47
+ parser.add_argument(
48
+ "--verbose",
49
+ action="store_true",
50
+ help="Verbose output for the paths"
51
+ )
52
+ args = parser.parse_args()
53
+
54
+ start_hub = graph.findClosestHub(["airport"], args.start)
55
+ end_hub = graph.findClosestHub(["airport"], args.end)
56
+
57
+ if start_hub is None or end_hub is None:
58
+ print("One of the airports does not exist in the graph")
59
+ return
60
+
61
+ route = graph.find_shortest_path(start_id=start_hub.id,
62
+ end_id=end_hub.id,
63
+ allowed_modes=args.allowedModes,
64
+ max_segments=args.maxSegments,
65
+ verbose=args.verbose)
66
+
67
+ print(route.flatPath if route else "No route found")
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
@@ -0,0 +1 @@
1
+ from .preprocessor import preprocessor # noqa: F401
@@ -0,0 +1,177 @@
1
+ # preprocessor.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
+ import pandas as pd
7
+ import os
8
+
9
+ # all datasets need:
10
+ # 1. source
11
+ # 2. destination
12
+ # 3. distance
13
+ # 4. source lat
14
+ # 5. source lng
15
+ # 6. destination lat
16
+ # 7. destination lng
17
+
18
+
19
+ class preprocessor:
20
+
21
+ @staticmethod
22
+ def _save(
23
+ df: pd.DataFrame,
24
+ targetType: str = "parquet"
25
+ ) -> None:
26
+ """
27
+ Save the DataFrame to a file in the data directory.
28
+
29
+ Parameters:
30
+ df (pd.DataFrame): The DataFrame to be saved.
31
+ targetType (str): The type of file to be saved. Defaults to "parquet".
32
+ """
33
+ from pathlib import Path
34
+
35
+ # create data directory if it doesn't exist (should be on te same level as this parent folder)
36
+ data_dir = Path(__file__).parent.parent / "data"
37
+ data_dir.mkdir(parents=True, exist_ok=True)
38
+ file_path = data_dir / f"fullDataset.{targetType}"
39
+
40
+ if targetType == "csv":
41
+ # Save the DataFrame to a csv file
42
+ df.to_csv(file_path, index=False)
43
+ else:
44
+ # Save the DataFrame to a parquet file
45
+ df.to_parquet(file_path, engine="pyarrow")
46
+
47
+ @staticmethod
48
+ def preprocess(
49
+ path: str,
50
+ sourceKey: str = "source",
51
+ sourceNameKey: str = "source_name",
52
+ destinationKey: str = "destination",
53
+ destinationNameKey: str = "destination_name",
54
+ distanceKey: str = "distance",
55
+ sourceLatKey: str = "source_lat",
56
+ sourceLngKey: str = "source_lng",
57
+ destinationLatKey: str = "destination_lat",
58
+ destinationLngKey: str = "destination_lng",
59
+ targetType: str = "parquet"
60
+ ) -> pd.DataFrame:
61
+ """
62
+ Preprocess a dataset by renaming columns to the desired format,
63
+ calculating distances and adding the result to the dataframe.
64
+
65
+ Parameters:
66
+ path (str): path to the dataset
67
+ sourceKey (str): key for the source column (default: "source")
68
+ destinationKey (str): key for the destination column (default: "destination")
69
+ distanceKey (str): key for the distance column (default: "distance")
70
+ sourceLatKey (str): key for the source latitude column (default: "source_lat")
71
+ sourceLngKey (str): key for the source longitude column (default: "source_lng")
72
+ destinationLatKey (str): key for the destination latitude column (default: "destination_lat")
73
+ destinationLngKey (str): key for the destination longitude column (default: "destination_lng")
74
+
75
+ Returns:
76
+ pd.DataFrame: the preprocessed dataframe
77
+ """
78
+
79
+ # check if file exists and read it into a df
80
+ _, fType = os.path.splitext(path)
81
+ if fType == ".csv":
82
+ df = pd.read_csv(path)
83
+ elif fType == ".parquet":
84
+ df = pd.read_parquet(path)
85
+
86
+ # get all column names
87
+ cols = list(df.columns)
88
+
89
+ # check if all required columns are present
90
+ if any([
91
+ sourceKey not in cols,
92
+ sourceNameKey not in cols,
93
+ destinationKey not in cols,
94
+ destinationNameKey not in cols,
95
+ sourceLatKey not in cols,
96
+ sourceLngKey not in cols,
97
+ destinationLatKey not in cols,
98
+ destinationLngKey not in cols
99
+ ]):
100
+ raise Exception("Invalid dataset")
101
+
102
+ # rename columns to the desired format
103
+ df.rename(columns={
104
+ sourceKey: "source",
105
+ sourceNameKey: "source_name",
106
+ destinationKey: "destination",
107
+ destinationNameKey: "destination_name",
108
+ sourceLatKey: "source_lat",
109
+ sourceLngKey: "source_lng",
110
+ destinationLatKey: "destination_lat",
111
+ destinationLngKey: "destination_lng",
112
+ **({distanceKey: "distance"} if distanceKey in cols else {})
113
+ }, inplace=True)
114
+
115
+ # distance is already present return here
116
+ if distanceKey in cols:
117
+ preprocessor._save(df, targetType=targetType)
118
+ return df[[
119
+ "source",
120
+ "source_name",
121
+ "destination",
122
+ "destination_name",
123
+ "distance",
124
+ "source_lat",
125
+ "source_lng",
126
+ "destination_lat",
127
+ "destination_lng"
128
+ ]]
129
+
130
+ # calculate distance
131
+ df["distance"] = preprocessor.haversine(df)
132
+
133
+ # save df
134
+ preprocessor._save(df, targetType=targetType)
135
+ # return processed df
136
+ return df[[
137
+ "source",
138
+ "source_name"
139
+ "destination",
140
+ "destination_name",
141
+ "distance",
142
+ "source_lat",
143
+ "source_lng",
144
+ "destination_lat",
145
+ "destination_lng"
146
+ ]]
147
+
148
+ @staticmethod
149
+ def haversine(df: pd.DataFrame) -> float:
150
+ # use torch for vector calculation
151
+ import torch
152
+ # set device
153
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
154
+ # compute vectorized haversine
155
+ with torch.no_grad():
156
+ # convert to radians
157
+ lat1 = torch.deg2rad(torch.tensor(df["source_lat"].values, device=device))
158
+ lng1 = torch.deg2rad(torch.tensor(df["source_lng"].values, device=device))
159
+ lat2 = torch.deg2rad(torch.tensor(df["destination_lat"].values, device=device))
160
+ lng2 = torch.deg2rad(torch.tensor(df["destination_lng"].values, device=device))
161
+
162
+ # compute delta lat and delta lng
163
+ dlat = lat2 - lat1
164
+ dlng = lng2 - lng1
165
+ # compute haversine
166
+ a = torch.sin(dlat / 2)**2 + torch.cos(lat1) * torch.cos(lat2) * torch.sin(dlng / 2)**2
167
+ c = 2 * torch.atan2(torch.sqrt(a), torch.sqrt(1 - a))
168
+
169
+ distances = 6371 * c
170
+
171
+ return distances.cpu().numpy()
172
+
173
+ @staticmethod
174
+ def combine(df1: pd.DataFrame, df2: pd.DataFrame) -> pd.DataFrame:
175
+ # Combine the two DataFrames
176
+ combined_df = pd.concat([df1, df2], axis=0)
177
+ return combined_df
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: multimodalrouter
3
+ Version: 0.1.0
4
+ Summary: A graph-based routing library for dynamic routing.
5
+ Author-email: Tobias Karusseit <karusseittobi@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2025 Tobias Karusseit
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
15
+
16
+
17
+ Requires-Python: >=3.11
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE.md
20
+ Requires-Dist: colorama>=0.4.6
21
+ Requires-Dist: dill>=0.4.0
22
+ Requires-Dist: filelock>=3.19.1
23
+ Requires-Dist: fsspec>=2025.9.0
24
+ Requires-Dist: Jinja2>=3.1.6
25
+ Requires-Dist: MarkupSafe>=3.0.2
26
+ Requires-Dist: mpmath>=1.3.0
27
+ Requires-Dist: networkx>=3.5
28
+ Requires-Dist: numpy>=2.3.3
29
+ Requires-Dist: pandas>=2.3.2
30
+ Requires-Dist: parquet>=1.3.1
31
+ Requires-Dist: ply>=3.11
32
+ Requires-Dist: pyarrow>=21.0.0
33
+ Requires-Dist: python-dateutil>=2.9.0.post0
34
+ Requires-Dist: pytz>=2025.2
35
+ Requires-Dist: setuptools>=80.9.0
36
+ Requires-Dist: six>=1.17.0
37
+ Requires-Dist: sympy>=1.14.0
38
+ Requires-Dist: thriftpy2>=0.5.3
39
+ Requires-Dist: tqdm>=4.67.1
40
+ Requires-Dist: typing_extensions>=4.15.0
41
+ Requires-Dist: tzdata>=2025.2
42
+ Provides-Extra: torch
43
+ Requires-Dist: torch>=2.8.0; extra == "torch"
44
+ Dynamic: license-file
45
+
46
+ # Multi Modal Router
47
+
48
+ The Multi Modal Router is a graph-based routing engine that allows you to build and query any hub-based network. It supports multiple transport modes like driving, flying, or shipping, and lets you optimize routes by distance, time, or custom metrics. It can be expanded to any n-dimensional space making it versatile in any coordinate space
49
+
50
+ > NOTE: This project is a work in progress and features might be added and or changed
51
+
52
+ # In depth Documentation
53
+
54
+ [installation guide](./docs/installation.md)
55
+
56
+ [graph module documentation](./docs/graph.md)
57
+
58
+ [code examples](./docs/examples/demo.py)
59
+
60
+ [command line interface documentation](./docs/cli.md)
61
+
62
+ [utilities documentation](./docs/utils.md)
63
+
64
+
65
+ # Features
66
+
67
+ ## Building Freedom / Unlimited Usecases
68
+
69
+ The graph can be build from any data aslong as the required fields are present ([example](./docs/examples/demoData.csv)). Whether your data contains real world places or you are working in a more abstract spaces with special coordinates and distance metrics the graph will behave the same (with minor limitations due to dynamic distance calculation, but not a problem when distances are already precomputed. [solutions](./docs/graph.md#advanced-options)).
70
+
71
+ #### Example Usecases
72
+
73
+ - real world flight router
74
+ - uses data with real flight data and actuall airport coordinates
75
+ - builds a graph with `airport` [Hubs](./docs/graph.md#hub)
76
+ - connects `airports` based on flight routes
77
+ - `finds` the `shortest flights` or `multi leg routes` to get from `A` to `B`
78
+ - simple example implementation [here](./docs/examples/flightRouter/main.py)
79
+
80
+ - social relation ship graph
81
+ - uses user data like a social network where users are connected through others via a group of other users
82
+ - builds a graph with `users` as Hubs
83
+ - connects users based on know interactions or any other connection meric
84
+ - `finds` users that are likely to `share`; `interests`, `friends`, `a social circle`, etc.
85
+
86
+ - coordinate based game AI and pathfinding
87
+ - uses a predefined path network (e.g. a simple maze)
88
+ - `builds` the garph representation of the network
89
+ - `finds` the shortest way to get from any point `A` to any other point `B` in the network
90
+ - you can checkout a simple example implementation for a maze pathfinder [here](./docs/examples/mazePathfinder/main.py)
91
+
92
+ ![example from the maze solver](./docs/solvedMaze1.png)
93
+
94
+ ## Important considerations for your usecase
95
+
96
+ Depending on your usecase and datasets some features may not be usable see solutions below
97
+
98
+ ### potential problems based on use case
99
+
100
+ **Please check your data for the following**
101
+
102
+ | distance present | coordinate format | unusable features | special considerations |
103
+ |------------------|-------------------|-------------------|------------------------|
104
+ | YES | degrees | None | None|
105
+ | YES|not degrees| runtime distance calculations| set [drivingEnabled = False](./docs/graph.md#args) or do [this](./docs/graph.md#swap-distance-method)|
106
+ | NO | degrees | None | distances must be calculated when [preprocessing](./src/multimodalrouter/utils/preprocessor.py) |
107
+ | NO | not degrees | **ALL** | **U cant build the graph with neither distances or supported coordinates!** [**solution**](./docs/graph.md#swap-distance-method)
108
+
109
+ [**example dataframe with the required fields**](./docs/examples/demoData.csv)
110
+
111
+ ### License
112
+
113
+ [see here](./LICENSE.md)
114
+
115
+
@@ -0,0 +1,15 @@
1
+ multimodalrouter/__init__.py,sha256=3O9ft058b5x09ORYD8Luw1qyEYoAx4kFPMjBs2HkfBM,238
2
+ multimodalrouter/graph/__init__.py,sha256=FSIR6ZN-IM0r-NRcM81AiPn2FVe9fA8rkc9r2746Lr4,142
3
+ multimodalrouter/graph/dataclasses.py,sha256=Bmcg3NFfFyPJS2sJO-IO0TnNCJ_pCG_n9lfJKVvFjxI,3535
4
+ multimodalrouter/graph/graph.py,sha256=UPFcFIxhlJ1gfpHG8mPWAGiRjxVgEy_VTv16AneSjYM,19944
5
+ multimodalrouter/router/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ multimodalrouter/router/build.py,sha256=jrGcFyVS7-Qg6CXAVVwiXM_krLBHBoRH85Slknmutns,2843
7
+ multimodalrouter/router/route.py,sha256=5RZGFgrTYm930RJX6i26d8f8_k7CGnp5lKZJBM1gcKk,1956
8
+ multimodalrouter/utils/__init__.py,sha256=jsZ7cBB-9otxzMiN9FVviGuknxnOPmaG2RBbZuiezeg,53
9
+ multimodalrouter/utils/preprocessor.py,sha256=45ya0cg0PGCV3YMk680_HZUve1QGJ7JHPSHoRvYdleY,6333
10
+ multimodalrouter-0.1.0.dist-info/licenses/LICENSE.md,sha256=CRtvaQsLnzHvSIzusV5sHHw-e8w8gytXq8R7AP1GBmE,1092
11
+ multimodalrouter-0.1.0.dist-info/METADATA,sha256=3RjttBFNt2KNlw5XUijSc-wGVKid5PbL8-BpQxplX4k,5766
12
+ multimodalrouter-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
13
+ multimodalrouter-0.1.0.dist-info/entry_points.txt,sha256=vp177Z2KMWPGJkS_dVpX05LVtBYssdlyYhTCG0kYmjo,138
14
+ multimodalrouter-0.1.0.dist-info/top_level.txt,sha256=4RYMG9hyl8mDNJ_gTrlh8QdYjZNXLDBzFVK1PcTpAYg,17
15
+ multimodalrouter-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ multiModalRouter-build = multimodalrouter.router.build:main
3
+ multiModalRouter-route = multimodalrouter.router.route:main
@@ -0,0 +1,10 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Tobias Karusseit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10
+
@@ -0,0 +1 @@
1
+ multimodalrouter