diff --git a/docs/graph.md b/docs/graph.md index 6b26192..f256e1e 100644 --- a/docs/graph.md +++ b/docs/graph.md @@ -2,9 +2,13 @@ [graph](#routegraph) +- [init](#initialization) +- [path finding / routing](#routing--finding-the-shortest-path-form-a-to-b) + - [advanced options](#advanced-options) - [custom distance metrics](#swap-distance-method) - [higher dimensional graphs](#higher-dimensional-graphs) + - [Search Filters](#custom-filters-in-searches) [dataclasses](#dataclasses) @@ -14,6 +18,7 @@ [Route](#route) +[Filters](#filter) # RouteGraph The `RouteGraph` is the central class of the package implemented as a dynamic (cyclic) directed graph. It defines all graph building and routing related functions. @@ -146,6 +151,37 @@ def find_shortest_path( **returns** : [Route](#route) or None if no route was found +--- + +### routing / finding the shortest path with multiple targets + +```python +def find_shortest_paths( + self, + start_id: str, + end_ids: list[str], + allowed_modes: list[str] | None = None, + optimization_metric: OptimizationMetric | str = OptimizationMetric.DISTANCE, + max_segments: int = 10, + verbose: bool = False, + custom_filter: Filter | None = None, +) -> dict[str, Route | VerboseRoute]: +``` + +#### args: + +- start_id: str = the id of the start point for all routes +- end_ids: list[str] = a list of all the target ids for the search (will find a sepperate route from start to every target) +- allowed_modes: list[str] = list of allowed transport Modes (pass `None` to allow all) +- optimization_metric: str | OptimizationMetric = the cost factor that the router will minimize +- max_segments: int = the search depth (routes with more than n segments are not explored) +- verbose: bool = whether to return verbose routes or not +- custom_filter: Filter | None = Filter to add custom restrictions to routing + +**returns** : dict[str, Route | VerboseRoute] = a dict where the key is the target_id and the value is the route to that id + + +--- ### radial search /finding all hubs inside a radius > Note: this doesn't search a direct radius but rather a reachablity distance (e.g.: A and B may have a distance $x \leq r$, but the shortest connecting path has distance $y \geq r$) @@ -384,54 +420,6 @@ nDimGraph = RouteGraph( > It is theoretically possible to combine hubs from differnt dimensions as long as a distance metric is given or the distance is pre calculated -#### custom filters in searches - -To add custom rulesets to searches like [`find_shortest_path`](#routing--finding-the-shortest-path-form-a-to-b) you can add your own [`Filter`](#filter) objects - -#### example - -Imagine one of your datasets has the following keys - -```csv -source, destination, distance, cost, sx, sy, dx, dy, namex, namey -``` - -You have now build your graph with the extra keys: `cost`, `namex`,`namey`, and you want to start a shortest path search that excludes edges where `cost` > `C` and the where the destination `namey` = `N`. Additionally you want to exclude a list of `hub Ids` = `I` - -**create Filter:** - -```python -from multimodalrouter import Filter - -class CustomFilter(Filter): - - def __init__(self, C: float, N: str, I: list[str]): - self.C = C - self.N = N - self.I = I - - def filterHub(self, hub: Hub): - return hub.id not in self.I - - def filterEdge(self, edge: EdgeMetadata): - return (edge.getMetric('cost') < self.C - and egde-getMetric('namey') != self.N - ) -``` - -**use filter** - -```python -# graph creation code here - -route = graph.find_shortest_path( - **kwargs, - custom_filter=CustomFilter(c, n, i) # your filter instance -) -``` ---- ---- ---- ## Dataclasses @@ -594,6 +582,8 @@ def asGraph(self, graph): **NOTES** if the given graph is missing some hubs from the route the created graph will skip the missing hubs and include new edges to connect the present hubs. (The new edges will only include the `distance` metric, which is calculated by the passed graph's distance function) +--- +--- ### Filter The `Filter` class is an abstract class you can implement to add custom filter to you searches @@ -633,6 +623,104 @@ def filterHub(self, hub: Hub) -> bool: will let any hub pass through the filter +#### custom filters in searches + +To add custom rulesets to searches like [`find_shortest_path`](#routing--finding-the-shortest-path-form-a-to-b) you can add your own [`Filter`](#filter) objects + +#### example + +Imagine one of your datasets has the following keys + +```csv +source, destination, distance, cost, sx, sy, dx, dy, namex, namey +``` + +You have now build your graph with the extra keys: `cost`, `namex`,`namey`, and you want to start a shortest path search that excludes edges where `cost` > `C` and the where the destination `namey` = `N`. Additionally you want to exclude a list of `hub Ids` = `I` + +**create Filter:** + +```python +from multimodalrouter import Filter + +class CustomFilter(Filter): + + def __init__(self, C: float, N: str, I: list[str]): + self.C = C + self.N = N + self.I = I + + def filterHub(self, hub: Hub): + return hub.id not in self.I + + def filterEdge(self, edge: EdgeMetadata): + return (edge.getMetric('cost') < self.C + and egde-getMetric('name') != self.N + ) +``` + +**use filter** + +```python +# graph creation code here + +route = graph.find_shortest_path( + **kwargs, + custom_filter=CustomFilter(c, n, i) # your filter instance +) +``` + +#### create filter for path inspection + +Filters can also inspect the current path. This enables you to add restrictions to the path topology. + +To add filter behaviour you can override the `filter()` method. + +```python +from multimodalrouter import Filter + +class CustomFilter(Filter): + + def filterHub(self, hub: Hub): return True + + def filterEdge(self, edge: EdgeMetadata): return True + + def filter( + self, + start: Hub, + end: Hub, + edge: EdgeMetadata, + path: PathNode | None = None + ) -> bool: + """ + filters routes that + have 10 consecutive legs with the same type + """ + # declare vars + N = 10 + n = 0 + target = "a" + if target != edge.transportMode: return True + # iter over the path + for node in path: + if target != node.edge.transportMode: break + n += 1 + return n <= N +``` + +**use filter** + +```python +# graph creation code here + +route = graph.find_shortest_path( + **kwargs, + custom_filter=CustomFilter() # your filter instance +) +``` +--- +--- +--- + diff --git a/pyproject.toml b/pyproject.toml index 9d7d37e..3de8160 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "multimodalrouter" -version = "0.1.9" +version = "0.1.10" description = "A graph-based routing library for dynamic routing." readme = "README.md" license = { file = "LICENSE.md" } diff --git a/src/multimodalrouter/__init__.py b/src/multimodalrouter/__init__.py index 196bc1e..0d510e0 100644 --- a/src/multimodalrouter/__init__.py +++ b/src/multimodalrouter/__init__.py @@ -1,4 +1,7 @@ -from .graph import RouteGraph, Hub, EdgeMetadata, OptimizationMetric, Route, VerboseRoute, Filter +from .graph import RouteGraph, Hub, EdgeMetadata, OptimizationMetric, Route, VerboseRoute, Filter, PathNode from .utils import preprocessor -__all__ = ["RouteGraph", "Hub", "EdgeMetadata", "OptimizationMetric", "Route", "VerboseRoute", "preprocessor", "Filter"] +__all__ = [ + "RouteGraph", "Hub", "EdgeMetadata", + "OptimizationMetric", "Route", "VerboseRoute", "preprocessor", "Filter", "PathNode" +] diff --git a/src/multimodalrouter/graph/__init__.py b/src/multimodalrouter/graph/__init__.py index 305e33e..feef3d6 100644 --- a/src/multimodalrouter/graph/__init__.py +++ b/src/multimodalrouter/graph/__init__.py @@ -1,2 +1,2 @@ from .graph import RouteGraph # noqa: F401 -from .dataclasses import Hub, EdgeMetadata, OptimizationMetric, Route, VerboseRoute, Filter # noqa: F401 +from .dataclasses import Hub, EdgeMetadata, OptimizationMetric, Route, VerboseRoute, Filter, PathNode # noqa: F401 diff --git a/src/multimodalrouter/graph/dataclasses.py b/src/multimodalrouter/graph/dataclasses.py index babba8d..bace050 100644 --- a/src/multimodalrouter/graph/dataclasses.py +++ b/src/multimodalrouter/graph/dataclasses.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from enum import Enum from abc import abstractmethod, ABC +from typing import Iterable, Optional class OptimizationMetric(Enum): @@ -167,6 +168,26 @@ class VerboseRoute(Route): path: list[tuple[str, str, EdgeMetadata]] +@dataclass(frozen=True) +class PathNode: + hub_id: str + mode: str + edge: EdgeMetadata + prev: Optional["PathNode"] + + @property + def length(self) -> int: + return 0 if self.prev is None else self.prev.length + 1 + + def __iter__(self) -> Iterable["PathNode"]: + node = self + stack = [] + while node: + stack.append(node) + node = node.prev + yield from reversed(stack) + + class Filter(ABC): @abstractmethod @@ -195,5 +216,5 @@ def filterHub(self, hub: Hub) -> bool: """ pass - def filter(self, start: Hub, end: Hub, edge: EdgeMetadata, current_path: list = None) -> bool: + def filter(self, start: Hub, end: Hub, edge: EdgeMetadata, path: PathNode | None = None) -> bool: return self.filterHub(start) and self.filterHub(end) and self.filterEdge(edge) diff --git a/src/multimodalrouter/graph/graph.py b/src/multimodalrouter/graph/graph.py index cd2e49b..1f0edab 100644 --- a/src/multimodalrouter/graph/graph.py +++ b/src/multimodalrouter/graph/graph.py @@ -9,7 +9,7 @@ import heapq import os import pandas as pd -from .dataclasses import Hub, EdgeMetadata, OptimizationMetric, Route, Filter, VerboseRoute +from .dataclasses import Hub, EdgeMetadata, OptimizationMetric, Route, Filter, VerboseRoute, PathNode from threading import Lock from collections import deque @@ -340,6 +340,131 @@ def _hubToHubDistances(self, hub1: list[Hub], hub2: list[Hub]): return distances.cpu().numpy() + def _dijkstra_single_source( + self, + start_id: str, + target_ids: set[str], + allowed_modes: list[str], + optimization_metric: OptimizationMetric, + max_segments: int, + custom_filter: Filter | None, + ): + + pq: list[tuple[float, str, PathNode, EdgeMetadata]] = [] + + start_metrics = EdgeMetadata() + start_path = PathNode( + hub_id=start_id, + mode="", + edge=EdgeMetadata(), + prev=None, + ) + + heapq.heappush(pq, (0.0, start_id, start_path, start_metrics)) + + # visited[(hub_id, path_len)] = best_metric + visited: dict[tuple[str, int], float] = {} + + # best result per target + results: dict[str, tuple[PathNode, EdgeMetadata]] = {} + + while pq: + current_metric, hub_id, path_node, acc_metrics = heapq.heappop(pq) + + path_len = path_node.length if path_node is not None else 0 + state = (hub_id, path_len) + + if state in visited and visited[state] <= current_metric: + continue + visited[state] = current_metric + + # record result if this hub is a target + if hub_id in target_ids: + prev = results.get(hub_id) + if prev is None or current_metric < prev[1].getMetric(optimization_metric): + results[hub_id] = (path_node, acc_metrics) + + if path_len >= max_segments: + continue + + current_hub = self.getHubById(hub_id) + if current_hub is None: + continue + + for mode in allowed_modes: + if mode not in current_hub.outgoing: + continue + + for next_hub_id, conn_metrics in current_hub.outgoing[mode].items(): + if conn_metrics is None: + continue + + next_hub = self.getHubById(next_hub_id) + if next_hub is None: + continue + + if custom_filter is not None: + if not custom_filter.filter( + current_hub, + next_hub, + conn_metrics, + path_node, + ): + continue + + edge_cost = conn_metrics.getMetric(optimization_metric) + new_metric = current_metric + edge_cost + + new_acc_metrics = EdgeMetadata( + transportMode=None, + **acc_metrics.metrics, + ) + for k, v in conn_metrics.metrics.items(): + if isinstance(v, (int, float)): + new_acc_metrics.metrics[k] = ( + new_acc_metrics.metrics.get(k, 0) + v + ) + else: + new_acc_metrics.metrics[k] = v + + new_path_node = PathNode( + hub_id=next_hub_id, + mode=mode, + edge=conn_metrics, + prev=path_node, + ) + + heapq.heappush( + pq, + (new_metric, next_hub_id, new_path_node, new_acc_metrics), + ) + + return results + + def _build_route( + self, + path_node: PathNode, + acc_metrics: EdgeMetadata, + optimization_metric: OptimizationMetric, + verbose: bool, + ) -> Route: + if verbose: + path = [ + (n.hub_id, n.mode, n.edge) + for n in path_node + ] + else: + path = [ + (n.hub_id, n.mode) + for n in path_node + ] + + return Route( + path=path, + totalMetrics=acc_metrics, + optimizedMetric=optimization_metric, + ) + # ============= public key functions ============= def build(self): @@ -376,140 +501,77 @@ def find_shortest_path( self, start_id: str, end_id: str, - allowed_modes: list[str] = None, + allowed_modes: list[str] | None = None, optimization_metric: OptimizationMetric | str = OptimizationMetric.DISTANCE, max_segments: int = 10, verbose: bool = False, - custom_filter: Filter = None, + custom_filter: Filter | None = None, ) -> Route | VerboseRoute | None: - """ - Find the optimal path between two hubs using Dijkstra - - Args: - start_id: ID of the starting hub - end_id: ID of the destination hub - optimization_metric: Metric to optimize for (distance, time, cost, etc.) (must exist in EdgeMetadata) - allowed_modes: List of allowed transport modes (default: all modes) - max_segments: Maximum number of segments allowed in route - - Returns: - Route object with the optimal path, or None if no path exists - """ - - # check if start and end hub exist - start_hub = self.getHubById(start_id) - end_hub = self.getHubById(end_id) + if not isinstance(end_id, str): + raise TypeError("end_id must be a single hub id (str)") + + results = self._dijkstra_single_source( + start_id=start_id, + target_ids={end_id}, + allowed_modes=allowed_modes, + optimization_metric=optimization_metric, + max_segments=max_segments, + custom_filter=custom_filter, + ) + + if end_id not in results: + return None - if start_hub is None: - raise ValueError(f"Start hub '{start_id}' not found in graph") - if end_hub is None: - raise ValueError(f"End hub '{end_id}' not found in graph") + path_node, acc_metrics = results[end_id] + return self._build_route( + path_node, + acc_metrics, + optimization_metric, + verbose, + ) - if allowed_modes is None: - allowed_modes = list(self.TransportModes.values()) - if self.drivingEnabled: - allowed_modes.append("car") - - if start_id == end_id: - # create a route with only the start hub - # no verbose since no edges are needed - return Route( - path=[(start_id, "")], - totalMetrics=EdgeMetadata(), - optimizedMetric=optimization_metric, + def find_shortest_paths( + self, + start_id: str, + end_ids: list[str], + allowed_modes: list[str] | None = None, + optimization_metric: OptimizationMetric | str = OptimizationMetric.DISTANCE, + max_segments: int = 10, + verbose: bool = False, + custom_filter: Filter | None = None, + ) -> dict[str, Route | VerboseRoute]: + if not end_ids: + return {} + + target_ids = set(end_ids) + + results = self._dijkstra_single_source( + start_id=start_id, + target_ids=target_ids, + allowed_modes=allowed_modes, + optimization_metric=optimization_metric, + max_segments=max_segments, + custom_filter=custom_filter, + ) + + routes: dict[str, Route | VerboseRoute] = {} + + for dst, (path_node, acc_metrics) in results.items(): + routes[dst] = self._build_route( + path_node, + acc_metrics, + optimization_metric, + verbose, ) - if verbose: - # priority queue: (metric_value, hub_id, path_with_modes, accumulated_metrics) - pq = [(0.0, start_id, [(start_id, "", EdgeMetadata())], EdgeMetadata())] - else: - # priority queue: (metric_value, hub_id, path_with_modes, accumulated_metrics) - pq = [(0.0, start_id, [(start_id, "")], EdgeMetadata())] - - visited = {} # dict like {hub_id : metric_value} + return routes - while pq: - # get the current path data - # optim metric, hub id, path with modes, accumulated metrics (edgeMetadata object) - current_metric_value, current_hub_id, path_with_modes, accumulated_metrics = heapq.heappop(pq) - - # skip this if a better path exists - if current_hub_id in visited and visited[current_hub_id] <= current_metric_value: - continue - # mark as visited - visited[current_hub_id] = current_metric_value - - # check if this is the end hub - if current_hub_id == end_id: - if verbose: - return Route( - path=path_with_modes, - totalMetrics=accumulated_metrics, - optimizedMetric=optimization_metric, - ) - - return Route( - path=path_with_modes, - totalMetrics=accumulated_metrics, - optimizedMetric=optimization_metric, - ) - - # skip if too many segments - if len(path_with_modes) > max_segments: - continue - - # get the current hub - current_hub = self.getHubById(current_hub_id) - if current_hub is None: - continue - - # test all outgoing connections from the current hub - for mode in allowed_modes: # iter over the allowed transport modes - if mode in current_hub.outgoing: # check if the mode has outgoing connections - # iter over all outgoing links with the selected transport type - for next_hub_id, connection_metrics in current_hub.outgoing[mode].items(): - if connection_metrics is None: # skip if the connection has no metrics - continue - - try: - next_hub = self.getHubById(next_hub_id) - except KeyError: - raise ValueError( - f"Hub with ID '{next_hub_id}' not found in graph! But it is connected to hub '{current_hub_id}' via mode '{mode}'." # noqa: E501 - ) - if ( - custom_filter is not None and - not custom_filter.filter(current_hub, next_hub, connection_metrics, path_with_modes) - ): - continue - - # get the selected metric alue for this connection - connection_value = connection_metrics.getMetric(optimization_metric) - new_metric_value = current_metric_value + connection_value - - # skip if a better hub to get here exists - if next_hub_id in visited and visited[next_hub_id] <= new_metric_value: - continue - - # create a new edge obj for the combined metrics | None bc modes my change between edges - new_accumulated_metrics = EdgeMetadata(transportMode=None, **accumulated_metrics.metrics) - # accumulate metrics - for metric_name, metric_value in connection_metrics.metrics.items(): - if isinstance(metric_value, (int, float)): - new_accumulated_metrics.metrics[metric_name] = new_accumulated_metrics.metrics.get(metric_name, 0) + metric_value # noqa: E501 - else: - # ignore non-numeric metrics for accumulation (maybe combine strings here) - new_accumulated_metrics.metrics[metric_name] = metric_value - - # combine to form a new path - if verbose: - new_path = path_with_modes + [(next_hub_id, mode, connection_metrics)] - else: - new_path = path_with_modes + [(next_hub_id, mode)] - # push to the priority queue for future exploration - heapq.heappush(pq, (new_metric_value, next_hub_id, new_path, new_accumulated_metrics)) - - return None + def fully_connect_points(self, point_ids: list[str], **kwargs): + graph = {} + for start in point_ids: + targets = [p for p in point_ids if p != start] + graph[start] = self.find_shortest_paths(start, targets, **kwargs) + return graph def radial_search( self, diff --git a/tests/unit/test_routegraph_public_features.py b/tests/unit/test_routegraph_public_features.py index a29f175..4e3859b 100644 --- a/tests/unit/test_routegraph_public_features.py +++ b/tests/unit/test_routegraph_public_features.py @@ -1,6 +1,6 @@ import unittest from unittest.mock import patch -from multimodalrouter import RouteGraph, Hub, Filter, EdgeMetadata +from multimodalrouter import RouteGraph, Hub, Filter, EdgeMetadata, PathNode import os import tempfile import io @@ -519,25 +519,30 @@ def filterEdge(self, edge: EdgeMetadata) -> bool: self.assertIn('A', reachableIds) def test_shortest_path_with_path_aware_filter(self): - """Test that filter can access and use the current path to limit consecutive segments.""" testDf = pd.DataFrame( - columns=['source', 'destination', 'distance', 'source_lat', 'source_lng', 'destination_lat', 'destination_lng'], + columns=[ + 'source', + 'destination', + 'distance', + 'source_lat', + 'source_lng', + 'destination_lat', + 'destination_lng', + ], data=[ ('A', 'B', 1, 1, 1, 1, 2), ('B', 'C', 1, 1, 2, 1, 3), ('C', 'D', 1, 1, 3, 1, 4), ('D', 'E', 1, 1, 4, 1, 5), ('E', 'F', 1, 1, 5, 1, 6), - ('A', 'F', 10, 1, 1, 1, 6), # Direct route is longer - ] + ('A', 'F', 10, 1, 1, 1, 6), + ], ) temp_path = os.path.join(self.temp_dir.name, 'temp_path_aware.csv') testDf.to_csv(temp_path, index=False) class PathAwareFilter(Filter): - """Filter that limits consecutive segments with the same transport mode.""" - def __init__(self, max_consecutive_segments: int = 2): self.max_consecutive_segments = max_consecutive_segments @@ -547,26 +552,27 @@ def filterHub(self, hub: Hub) -> bool: def filterEdge(self, edge: EdgeMetadata) -> bool: return True - def filter(self, start: Hub, end: Hub, edge: EdgeMetadata, current_path: list = None) -> bool: - if current_path is None or len(current_path) == 0: + def filter( + self, + start: Hub, + end: Hub, + edge: EdgeMetadata, + path: PathNode | None, + ) -> bool: + if path is None: return True mode = edge.transportMode - - # count consecutive segments with the same mode - consecutive_count = 0 - for i in range(len(current_path) - 1, -1, -1): - path_mode = current_path[i][1] if len(current_path[i]) > 1 else "" - if path_mode == mode: - consecutive_count += 1 + consecutive = 0 + node = path + while node is not None: + if node.mode == mode: + consecutive += 1 else: break + node = node.prev - # if more than max consecutive segments, return False - if consecutive_count >= self.max_consecutive_segments: - return False - - return True + return consecutive < self.max_consecutive_segments graph = RouteGraph( maxDistance=50, @@ -574,48 +580,49 @@ def filter(self, start: Hub, end: Hub, edge: EdgeMetadata, current_path: list = dataPaths={'H': temp_path}, compressed=False, extraMetricsKeys=[], - drivingEnabled=False + drivingEnabled=False, ) f = io.StringIO() with contextlib.redirect_stdout(f), contextlib.redirect_stderr(f): graph.build() - # search route that only ever has two consecutive segments of one mode route = graph.find_shortest_path( 'A', 'F', allowed_modes=['mv'], - custom_filter=PathAwareFilter(max_consecutive_segments=2) + custom_filter=PathAwareFilter(max_consecutive_segments=2), ) self.assertIsNotNone(route) - path = route.path - starts = [p[0] for p in path] - - # should take direct route A -> F (10 distance) instead of A -> B -> C (would be 3 segments) + starts = [p[0] for p in route.path] self.assertEqual(starts, ['A', 'F']) self.assertAlmostEqual(route.totalMetrics.getMetric('distance'), 10, places=5) - # without filter, should take the shorter multi-hop route route_no_filter = graph.find_shortest_path('A', 'F', allowed_modes=['mv']) self.assertIsNotNone(route_no_filter) - path_no_filter = route_no_filter.path - starts_no_filter = [p[0] for p in path_no_filter] - - # should be A -> B -> C -> D -> E -> F (5 distance total) + starts_no_filter = [p[0] for p in route_no_filter.path] self.assertEqual(starts_no_filter, ['A', 'B', 'C', 'D', 'E', 'F']) - self.assertAlmostEqual(route_no_filter.totalMetrics.getMetric('distance'), 5, places=5) + self.assertAlmostEqual( + route_no_filter.totalMetrics.getMetric('distance'), 5, places=5 + ) def test_shortest_path_with_path_aware_filter_verbose(self): - """Test path-aware filter works with verbose mode.""" testDf = pd.DataFrame( - columns=['source', 'destination', 'distance', 'source_lat', 'source_lng', 'destination_lat', 'destination_lng'], + columns=[ + 'source', + 'destination', + 'distance', + 'source_lat', + 'source_lng', + 'destination_lat', + 'destination_lng', + ], data=[ ('A', 'B', 1, 1, 1, 1, 2), ('B', 'C', 1, 1, 2, 1, 3), ('C', 'D', 1, 1, 3, 1, 4), ('A', 'D', 5, 1, 1, 1, 4), - ] + ], ) temp_path = os.path.join(self.temp_dir.name, 'temp_path_aware_verbose.csv') @@ -631,19 +638,25 @@ def filterHub(self, hub: Hub) -> bool: def filterEdge(self, edge: EdgeMetadata) -> bool: return True - def filter(self, start: Hub, end: Hub, edge: EdgeMetadata, current_path: list = None) -> bool: - if current_path is None or len(current_path) == 0: + def filter( + self, + start: Hub, + end: Hub, + edge: EdgeMetadata, + path: PathNode | None, + ) -> bool: + if path is None: return True mode = edge.transportMode consecutive = 0 - for i in range(len(current_path) - 1, -1, -1): - # Handle both verbose and non-verbose path formats - path_mode = current_path[i][1] if len(current_path[i]) > 1 else "" - if path_mode == mode: + node = path + while node is not None: + if node.mode == mode: consecutive += 1 else: break + node = node.prev return consecutive < self.max_consecutive @@ -653,7 +666,7 @@ def filter(self, start: Hub, end: Hub, edge: EdgeMetadata, current_path: list = dataPaths={'H': temp_path}, compressed=False, extraMetricsKeys=[], - drivingEnabled=False + drivingEnabled=False, ) f = io.StringIO() @@ -665,12 +678,127 @@ def filter(self, start: Hub, end: Hub, edge: EdgeMetadata, current_path: list = 'D', allowed_modes=['mv'], verbose=True, - custom_filter=PathAwareFilter(max_consecutive=1) + custom_filter=PathAwareFilter(max_consecutive=1), ) self.assertIsNotNone(route) - path = route.path - starts = [p[0] for p in path] - - # max 1 consecutive, forced to take direct route + starts = [p[0] for p in route.path] self.assertEqual(starts, ['A', 'D']) self.assertAlmostEqual(route.totalMetrics.getMetric('distance'), 5, places=5) + + def test_find_shortest_paths_one_to_many(self): + graph = RouteGraph( + maxDistance=50, + transportModes={'H': 'mv'}, + dataPaths={'H': self.temp_file_path}, + compressed=False, + extraMetricsKeys=[], + drivingEnabled=False, + ) + + with contextlib.redirect_stdout(io.StringIO()): + graph.build() + + routes = graph.find_shortest_paths( + start_id='A', + end_ids=['B', 'D'], + allowed_modes=['mv'], + ) + + self.assertIsInstance(routes, dict) + self.assertIn('B', routes) + self.assertIn('D', routes) + + route_B = routes['B'] + route_D = routes['D'] + + self.assertEqual([p[0] for p in route_B.path], ['A', 'B']) + self.assertEqual([p[0] for p in route_D.path], ['A', 'B', 'D']) + + self.assertAlmostEqual(route_B.totalMetrics.getMetric('distance'), 2) + self.assertAlmostEqual(route_D.totalMetrics.getMetric('distance'), 3) + + def test_fully_connect_points(self): + testDf = pd.DataFrame( + columns=['source', 'destination', 'distance', 'source_lat', 'source_lng', 'destination_lat', 'destination_lng'], + data=[ + ('A', 'B', 1, 0, 0, 1, 0), + ('B', 'C', 1, 1, 0, 2, 0), + ('C', 'D', 1, 2, 0, 3, 0), + ], + ) + + temp_path = os.path.join(self.temp_dir.name, 'fully_connect.csv') + testDf.to_csv(temp_path, index=False) + + graph = RouteGraph( + maxDistance=50, + transportModes={'H': 'mv'}, + dataPaths={'H': temp_path}, + compressed=False, + extraMetricsKeys=[], + drivingEnabled=False, + ) + + with contextlib.redirect_stdout(io.StringIO()): + graph.build() + + fc = graph.fully_connect_points( + ['A', 'B', 'C'], + allowed_modes=['mv'], + optimization_metric="distance", + ) + + self.assertIn('A', fc) + self.assertIn('B', fc) + self.assertIn('C', fc) + + self.assertIn('C', fc['A']) + self.assertEqual([p[0] for p in fc['A']['C'].path], ['A', 'B', 'C']) + + self.assertIn('C', fc['B']) + self.assertEqual([p[0] for p in fc['B']['C'].path], ['B', 'C']) + + def test_fully_connect_with_path_filter(self): + class MaxDepth(Filter): + def filterHub(self, hub): + return True + + def filterEdge(self, edge): + return True + + def filter(self, start, end, edge, path): + depth = 0 + for _ in path: # use this to test __iter__ + depth += 1 + return depth < 2 + + testDf = pd.DataFrame( + columns=['source', 'destination', 'distance', 'source_lat', 'source_lng', 'destination_lat', 'destination_lng'], + data=[ + ('A', 'B', 1, 0, 0, 1, 0), + ('B', 'C', 1, 1, 0, 2, 0), + ], + ) + + temp_path = os.path.join(self.temp_dir.name, 'fc_filter.csv') + testDf.to_csv(temp_path, index=False) + + graph = RouteGraph( + maxDistance=50, + transportModes={'H': 'mv'}, + dataPaths={'H': temp_path}, + compressed=False, + extraMetricsKeys=[], + drivingEnabled=False, + ) + + with contextlib.redirect_stdout(io.StringIO()): + graph.build() + + fc = graph.fully_connect_points( + ['A', 'C'], + allowed_modes=['mv'], + custom_filter=MaxDepth(), + ) + + self.assertNotIn('C', fc['A'])