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.
- multimodalrouter/__init__.py +4 -0
- multimodalrouter/graph/__init__.py +2 -0
- multimodalrouter/graph/dataclasses.py +106 -0
- multimodalrouter/graph/graph.py +513 -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.0.dist-info/METADATA +115 -0
- multimodalrouter-0.1.0.dist-info/RECORD +15 -0
- multimodalrouter-0.1.0.dist-info/WHEEL +5 -0
- multimodalrouter-0.1.0.dist-info/entry_points.txt +3 -0
- multimodalrouter-0.1.0.dist-info/licenses/LICENSE.md +10 -0
- multimodalrouter-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+

|
|
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,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
|