Skip to main content

rustsim_mobility/
router.rs

1//! Mode-tagged routing graph and Dijkstra router.
2//!
3//! This is a small multi-modal shortest-path engine. Nodes are opaque
4//! `u64` identifiers; edges carry a traversal `cost` and an
5//! [`rustsim_modes::AllowedModes`] permission mask. The router only
6//! relaxes edges whose permission mask intersects the traveller's
7//! available mode set.
8
9use std::cmp::Ordering;
10use std::collections::BinaryHeap;
11
12use rustsim_modes::{AllowedModes, TravelMode};
13use rustsim_traffic::{TransportLinkMetadata, TransportLinkOps, TransportLinkSpace};
14use thiserror::Error;
15
16/// Opaque node identifier in the routing graph.
17pub type NodeId = u64;
18
19/// A directed, mode-tagged edge.
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub struct ModalEdge {
22    /// Source node.
23    pub from: NodeId,
24    /// Destination node.
25    pub to: NodeId,
26    /// Traversal cost (typically seconds of travel time).
27    pub cost: f64,
28    /// Which modes are allowed on this edge.
29    pub modes: AllowedModes,
30}
31
32/// Mode-tagged routing graph.
33#[derive(Debug, Clone, Default)]
34pub struct ModalGraph {
35    /// Sorted adjacency list: `edges[from] = [edge, ...]`.
36    adjacency: hashbrown::HashMap<NodeId, Vec<ModalEdge>>,
37}
38
39/// Errors returned when building a modal graph from transport link metadata.
40#[derive(Debug, Clone, PartialEq, Error)]
41pub enum ModalGraphBuildError {
42    /// Link metadata referenced a link that does not exist in the link space.
43    #[error("transport metadata references missing link {0}")]
44    MissingLink(u64),
45    /// Link metadata endpoints disagree with the topology held by the link space.
46    #[error("transport metadata endpoints for link {link_id} are ({metadata_from}, {metadata_to}) but link space has ({space_from}, {space_to})")]
47    EndpointMismatch {
48        /// Link identifier.
49        link_id: u64,
50        /// Source node from metadata.
51        metadata_from: u64,
52        /// Destination node from metadata.
53        metadata_to: u64,
54        /// Source node from link space.
55        space_from: u64,
56        /// Destination node from link space.
57        space_to: u64,
58    },
59    /// A link's current travel time could not be used as a routing cost.
60    #[error("transport link {link_id} has invalid routing cost {cost}")]
61    InvalidCost {
62        /// Link identifier.
63        link_id: u64,
64        /// Computed cost.
65        cost: f64,
66    },
67}
68
69impl ModalGraph {
70    /// Empty graph.
71    pub fn new() -> Self {
72        Self::default()
73    }
74
75    /// Append an edge.
76    pub fn add_edge(&mut self, edge: ModalEdge) {
77        self.adjacency.entry(edge.from).or_default().push(edge);
78    }
79
80    /// Neighbours of `node`.
81    pub fn neighbours(&self, node: NodeId) -> &[ModalEdge] {
82        self.adjacency
83            .get(&node)
84            .map(|v| v.as_slice())
85            .unwrap_or(&[])
86    }
87
88    /// Build a mode-tagged graph from a `rustsim-traffic` link space and
89    /// matching transport metadata.
90    ///
91    /// Each metadata record becomes one directed [`ModalEdge`]. The edge cost
92    /// is the link's current snapshot travel time from
93    /// [`TransportLinkOps::link_travel_time`], so density and blocking effects
94    /// already represented in the link space are reflected in routing.
95    pub fn from_transport_links(
96        space: &TransportLinkSpace,
97        metadata: impl IntoIterator<Item = TransportLinkMetadata>,
98    ) -> Result<Self, ModalGraphBuildError> {
99        let mut graph = Self::new();
100        for link in metadata {
101            let link_id = link.edge_id;
102            let (space_from, space_to) = space
103                .link_endpoints(link_id)
104                .ok_or(ModalGraphBuildError::MissingLink(link_id as u64))?;
105            if (link.from_node, link.to_node) != (space_from, space_to) {
106                return Err(ModalGraphBuildError::EndpointMismatch {
107                    link_id: link_id as u64,
108                    metadata_from: link.from_node as u64,
109                    metadata_to: link.to_node as u64,
110                    space_from: space_from as u64,
111                    space_to: space_to as u64,
112                });
113            }
114
115            let cost = space.link_travel_time(link_id);
116            if !cost.is_finite() || cost < 0.0 {
117                return Err(ModalGraphBuildError::InvalidCost {
118                    link_id: link_id as u64,
119                    cost,
120                });
121            }
122
123            graph.add_edge(ModalEdge {
124                from: space_from as NodeId,
125                to: space_to as NodeId,
126                cost,
127                modes: link.allowed_modes,
128            });
129        }
130        Ok(graph)
131    }
132}
133
134/// Resulting route from [`shortest_path`].
135#[derive(Debug, Clone, PartialEq)]
136pub struct ModalRoute {
137    /// Ordered nodes from `start` to `goal`.
138    pub nodes: Vec<NodeId>,
139    /// Total cost along the route.
140    pub total_cost: f64,
141}
142
143/// Compute the lowest-cost path from `start` to `goal` using only edges
144/// that permit at least one mode in `available_modes`. Returns `None` if
145/// unreachable.
146pub fn shortest_path(
147    graph: &ModalGraph,
148    start: NodeId,
149    goal: NodeId,
150    available_modes: AllowedModes,
151) -> Option<ModalRoute> {
152    #[derive(Copy, Clone, PartialEq)]
153    struct State {
154        cost: f64,
155        node: NodeId,
156    }
157    impl Eq for State {}
158    impl Ord for State {
159        fn cmp(&self, other: &Self) -> Ordering {
160            other
161                .cost
162                .partial_cmp(&self.cost)
163                .unwrap_or(Ordering::Equal)
164        }
165    }
166    impl PartialOrd for State {
167        fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
168            Some(self.cmp(other))
169        }
170    }
171
172    let mut dist: hashbrown::HashMap<NodeId, f64> = hashbrown::HashMap::new();
173    let mut came_from: hashbrown::HashMap<NodeId, NodeId> = hashbrown::HashMap::new();
174    let mut heap = BinaryHeap::new();
175
176    dist.insert(start, 0.0);
177    heap.push(State {
178        cost: 0.0,
179        node: start,
180    });
181
182    while let Some(State { cost, node }) = heap.pop() {
183        if node == goal {
184            return Some(reconstruct(&came_from, start, goal, cost));
185        }
186        if let Some(&best) = dist.get(&node) {
187            if cost > best + 1e-9 {
188                continue;
189            }
190        }
191        for edge in graph.neighbours(node) {
192            if !modes_intersect(edge.modes, available_modes) {
193                continue;
194            }
195            let next_cost = cost + edge.cost;
196            let better = dist
197                .get(&edge.to)
198                .map(|&d| next_cost < d - 1e-12)
199                .unwrap_or(true);
200            if better {
201                dist.insert(edge.to, next_cost);
202                came_from.insert(edge.to, node);
203                heap.push(State {
204                    cost: next_cost,
205                    node: edge.to,
206                });
207            }
208        }
209    }
210
211    None
212}
213
214fn reconstruct(
215    came_from: &hashbrown::HashMap<NodeId, NodeId>,
216    start: NodeId,
217    goal: NodeId,
218    cost: f64,
219) -> ModalRoute {
220    let mut nodes = vec![goal];
221    let mut cur = goal;
222    while cur != start {
223        if let Some(&prev) = came_from.get(&cur) {
224            nodes.push(prev);
225            cur = prev;
226        } else {
227            break;
228        }
229    }
230    nodes.reverse();
231    ModalRoute {
232        nodes,
233        total_cost: cost,
234    }
235}
236
237/// Permission helper for single-mode callers.
238pub fn only(mode: TravelMode) -> AllowedModes {
239    AllowedModes::none().with_mode(mode)
240}
241
242/// Do two permission masks have at least one mode in common?
243fn modes_intersect(a: AllowedModes, b: AllowedModes) -> bool {
244    [
245        TravelMode::Pedestrian,
246        TravelMode::Vehicle,
247        TravelMode::Bicycle,
248        TravelMode::Transit,
249    ]
250    .iter()
251    .any(|m| a.allows(*m) && b.allows(*m))
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use rustsim_traffic::{LinkClass, LinkProperties};
258    use rustsim_traffic::{TrafficControlType, TransportLinkMetadata, TransportLinkSpace};
259
260    fn walk_plus_transit_graph() -> ModalGraph {
261        let mut g = ModalGraph::new();
262        // Walk network: 1 - 2 - 3 - 4 (slow).
263        let walk_only = only(TravelMode::Pedestrian);
264        g.add_edge(ModalEdge {
265            from: 1,
266            to: 2,
267            cost: 600.0,
268            modes: walk_only,
269        });
270        g.add_edge(ModalEdge {
271            from: 2,
272            to: 3,
273            cost: 600.0,
274            modes: walk_only,
275        });
276        g.add_edge(ModalEdge {
277            from: 3,
278            to: 4,
279            cost: 300.0,
280            modes: walk_only,
281        });
282        // Transit shortcut 2 -> 3 (fast).
283        let transit_only = only(TravelMode::Transit);
284        g.add_edge(ModalEdge {
285            from: 2,
286            to: 3,
287            cost: 120.0,
288            modes: transit_only,
289        });
290        g
291    }
292
293    #[test]
294    fn walk_only_takes_slow_path() {
295        let g = walk_plus_transit_graph();
296        let r = shortest_path(&g, 1, 4, only(TravelMode::Pedestrian)).unwrap();
297        assert_eq!(r.nodes, vec![1, 2, 3, 4]);
298        assert!((r.total_cost - 1500.0).abs() < 1e-9);
299    }
300
301    #[test]
302    fn walk_plus_transit_takes_transit_shortcut() {
303        let g = walk_plus_transit_graph();
304        let modes = AllowedModes::none()
305            .with_mode(TravelMode::Pedestrian)
306            .with_mode(TravelMode::Transit);
307        let r = shortest_path(&g, 1, 4, modes).unwrap();
308        assert_eq!(r.nodes, vec![1, 2, 3, 4]);
309        // Walk leg 1->2 (600) + transit 2->3 (120) + walk 3->4 (300) = 1020
310        assert!((r.total_cost - 1020.0).abs() < 1e-9);
311    }
312
313    #[test]
314    fn disconnected_returns_none() {
315        let g = walk_plus_transit_graph();
316        let r = shortest_path(&g, 1, 99, only(TravelMode::Pedestrian));
317        assert!(r.is_none());
318    }
319
320    #[test]
321    fn builds_graph_from_transport_link_space() {
322        let mut space = TransportLinkSpace::new();
323        let a = space.add_node();
324        let b = space.add_node();
325        let c = space.add_node();
326        let (walk_geom, walk_props) = LinkProperties::pedestrian(60.0, 3.0).unwrap();
327        let walk = space.add_link(a, b, walk_geom, walk_props).unwrap();
328        let (road_geom, road_props) = LinkProperties::urban(500.0, 40.0, 1).unwrap();
329        let road = space.add_link(b, c, road_geom, road_props).unwrap();
330
331        let metadata = vec![
332            TransportLinkMetadata::new(
333                walk,
334                a,
335                b,
336                LinkClass::Walkway,
337                AllowedModes::pedestrian_only(),
338            ),
339            TransportLinkMetadata::new(
340                road,
341                b,
342                c,
343                LinkClass::Transitway,
344                AllowedModes::vehicular(),
345            )
346            .with_traffic_control(TrafficControlType::Signal),
347        ];
348
349        let graph = ModalGraph::from_transport_links(&space, metadata).unwrap();
350        assert_eq!(graph.neighbours(a as NodeId).len(), 1);
351        assert_eq!(graph.neighbours(b as NodeId).len(), 1);
352        assert!(shortest_path(
353            &graph,
354            a as NodeId,
355            c as NodeId,
356            AllowedModes::none()
357                .with_mode(TravelMode::Pedestrian)
358                .with_mode(TravelMode::Transit),
359        )
360        .is_some());
361    }
362}