From 033c85a3381c4a8e2447feb2d08f0a158a30306f Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Sat, 26 Sep 2026 16:03:15 +0800 Subject: [PATCH] network: add shortest path and interval routing --- Cargo.lock | 5 + crates/network/apsis-routing/Cargo.toml | 3 + crates/network/apsis-routing/src/dijkstra.rs | 277 ++++++++++++++++ crates/network/apsis-routing/src/error.rs | 73 ++++ crates/network/apsis-routing/src/lib.rs | 53 +++ crates/network/apsis-routing/src/route.rs | 125 +++++++ .../network/apsis-routing/tests/dijkstra.rs | 313 ++++++++++++++++++ 7 files changed, 849 insertions(+) create mode 100644 crates/network/apsis-routing/src/dijkstra.rs create mode 100644 crates/network/apsis-routing/src/error.rs create mode 100644 crates/network/apsis-routing/src/route.rs create mode 100644 crates/network/apsis-routing/tests/dijkstra.rs diff --git a/Cargo.lock b/Cargo.lock index a9bcec9..4e25778 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -195,6 +195,11 @@ dependencies = [ [[package]] name = "apsis-routing" version = "0.1.0" +dependencies = [ + "apsis-core", + "apsis-math", + "apsis-topology", +] [[package]] name = "apsis-rs" diff --git a/crates/network/apsis-routing/Cargo.toml b/crates/network/apsis-routing/Cargo.toml index 5d0f8f7..ae6c7be 100644 --- a/crates/network/apsis-routing/Cargo.toml +++ b/crates/network/apsis-routing/Cargo.toml @@ -11,3 +11,6 @@ publish = false workspace = true [dependencies] +apsis-core.workspace = true +apsis-math.workspace = true +apsis-topology.workspace = true diff --git a/crates/network/apsis-routing/src/dijkstra.rs b/crates/network/apsis-routing/src/dijkstra.rs new file mode 100644 index 0000000..61b295a --- /dev/null +++ b/crates/network/apsis-routing/src/dijkstra.rs @@ -0,0 +1,277 @@ +//! Shortest path computation over a topology. +//! +//! The algorithm is Dijkstra's, using a binary heap keyed by the tentative +//! distance. Costs are positive, so a node's distance is final when it is +//! first removed from the heap, which makes the algorithm exact. +//! +//! The heap is a minimum heap over a reversed ordering key, because the +//! standard library's [`BinaryHeap`](std::collections::BinaryHeap) is a +//! maximum heap. The key is the tentative distance, and ties are broken by +//! the node index so that the result is deterministic for a given topology. + +use crate::error::{RoutingError, RoutingErrorKind}; +use crate::route::{Route, RouteLeg, RouteStep}; +use apsis_topology::{Topology, TopologySeries}; +use core::cmp::Ordering; +use std::collections::BinaryHeap; + +/// A heap entry holding a node and its tentative distance. +#[derive(Debug, Clone, Copy, PartialEq)] +struct Candidate { + /// The tentative distance from the source. + distance: f64, + /// The node index. + node: usize, +} + +impl Eq for Candidate {} + +impl Ord for Candidate { + fn cmp(&self, other: &Self) -> Ordering { + // Reverse the distance comparison so that the smallest distance is + // the greatest element, which is what the maximum heap needs. Equal + // distances are ordered by node index so that the sequence of + // relaxations is reproducible. + other + .distance + .partial_cmp(&self.distance) + .unwrap_or(Ordering::Equal) + .then_with(|| other.node.cmp(&self.node)) + } +} + +impl PartialOrd for Candidate { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// The result of a single source shortest path computation. +/// +/// The distances are to every node reachable from the source, and the +/// predecessor map records the edge by which each node was reached. Both are +/// what a routing table needs, and a single computation fills a whole row of +/// it. +#[derive(Debug, Clone, PartialEq)] +pub struct PathTree { + /// The source node. + pub source: usize, + /// The distance to each node, or infinity when it is unreachable. + pub distances: Vec, + /// The predecessor of each node on the shortest path, if any. + pub predecessors: Vec>, +} + +impl PathTree { + /// Returns the distance to a node. + /// + /// Returns `None` when the node index is out of range or the node is + /// unreachable. + pub fn distance(&self, node: usize) -> Option { + let distance = *self.distances.get(node)?; + distance.is_finite().then_some(distance) + } + + /// Returns `true` when the node is reachable from the source. + pub fn is_reachable(&self, node: usize) -> bool { + self.distance(node).is_some() + } + + /// Returns the number of reachable nodes, including the source itself. + pub fn reachable_count(&self) -> usize { + self.distances + .iter() + .filter(|value| value.is_finite()) + .count() + } + + /// Reconstructs the route to a node. + /// + /// Returns an error when the node is out of range or unreachable. + pub fn route_to(&self, destination: usize, topology: &Topology) -> Result { + if destination >= self.distances.len() { + return Err(RoutingError::new(RoutingErrorKind::NoSuchNode) + .with_detail(format!("node {destination} of {}", self.distances.len()))); + } + if !self.is_reachable(destination) { + return Err(RoutingError::new(RoutingErrorKind::NoRoute) + .with_detail(format!("no route from {} to {destination}", self.source))); + } + + // Walk the predecessor chain back to the source, then reverse it. + let mut nodes = vec![destination]; + let mut current = destination; + while current != self.source { + let previous = self.predecessors[current].ok_or_else(|| { + RoutingError::new(RoutingErrorKind::NoRoute) + .with_detail(format!("the predecessor chain breaks at {current}")) + })?; + nodes.push(previous); + current = previous; + } + nodes.reverse(); + + let mut steps = Vec::with_capacity(nodes.len().saturating_sub(1)); + for pair in nodes.windows(2) { + let cost = topology.cost(pair[0], pair[1]).ok_or_else(|| { + RoutingError::new(RoutingErrorKind::NoRoute).with_detail(format!( + "the link from {} to {} is absent", + pair[0], pair[1] + )) + })?; + steps.push(RouteStep::new(pair[0], pair[1], cost)); + } + Ok(Route::new(nodes, steps)) + } +} + +/// Computes the shortest paths from one source to every reachable node. +/// +/// Returns an error when the source node is out of range. +/// +/// Source: Dijkstra, E. W., *A note on two problems in connexion with +/// graphs*, Numerische Mathematik 1, 1959. +pub fn shortest_paths_from(topology: &Topology, source: usize) -> Result { + if source >= topology.node_count() { + return Err(RoutingError::new(RoutingErrorKind::NoSuchNode) + .with_detail(format!("node {source} of {}", topology.node_count()))); + } + + let mut distances = vec![f64::INFINITY; topology.node_count()]; + let mut predecessors = vec![None; topology.node_count()]; + let mut settled = vec![false; topology.node_count()]; + distances[source] = 0.0; + + let mut heap = BinaryHeap::new(); + heap.push(Candidate { + distance: 0.0, + node: source, + }); + + while let Some(Candidate { distance, node }) = heap.pop() { + // A stale entry: the node already has a shorter distance. + if settled[node] { + continue; + } + settled[node] = true; + + for edge in topology.edges().iter().filter(|edge| edge.from == node) { + let candidate = distance + edge.cost; + if candidate < distances[edge.to] { + distances[edge.to] = candidate; + predecessors[edge.to] = Some(node); + heap.push(Candidate { + distance: candidate, + node: edge.to, + }); + } + } + } + + Ok(PathTree { + source, + distances, + predecessors, + }) +} + +/// Returns the shortest route between two nodes. +/// +/// Returns an error when either node is out of range, when they are the +/// same node, or when no route exists. +pub fn shortest_path( + topology: &Topology, + source: usize, + destination: usize, +) -> Result { + if source == destination { + return Err(RoutingError::new(RoutingErrorKind::SameEndpoint) + .with_detail(format!("both endpoints are node {source}"))); + } + let tree = shortest_paths_from(topology, source)?; + tree.route_to(destination, topology) +} + +/// Returns the shortest route valid across an interval of a topology series. +/// +/// The route is computed over the links that are present throughout the +/// interval, so it remains valid for the whole of it. The interval is taken +/// from the start of the sample in force at `start_seconds` to the start of +/// the sample in force at `end_seconds`, and the links of every sample in +/// that span are intersected. +/// +/// The returned leg records the interval the route is valid for, which is +/// bounded by the next topology change after `end_seconds`. +/// +/// Returns an error when either node is out of range or the same, when the +/// interval is not ordered, or when no route survives the whole interval. +/// +/// Source: the store and forward routing model over a time-varying graph is +/// the one described in Fraire, J. A. et al., *Routing in Delay-Tolerant +/// Networks Comprising Heterogeneous Populations of Deterministic Link +/// Dynamics*, IEEE Transactions on Mobile Computing, 2020. +pub fn route_over_series( + series: &TopologySeries, + source: usize, + destination: usize, + start_seconds: f64, + end_seconds: f64, +) -> Result { + if !start_seconds.is_finite() || !end_seconds.is_finite() || end_seconds < start_seconds { + return Err( + RoutingError::new(RoutingErrorKind::InvalidInterval).with_detail(format!( + "the interval runs from {start_seconds} to {end_seconds}" + )), + ); + } + if source >= series.node_count() || destination >= series.node_count() { + return Err( + RoutingError::new(RoutingErrorKind::NoSuchNode).with_detail(format!( + "nodes {source} and {destination} of {}", + series.node_count() + )), + ); + } + if source == destination { + return Err(RoutingError::new(RoutingErrorKind::SameEndpoint) + .with_detail(format!("both endpoints are node {source}"))); + } + + let first = series.index_at(start_seconds).ok_or_else(|| { + RoutingError::new(RoutingErrorKind::InvalidInterval) + .with_detail("the series has no sample at the start of the interval") + })?; + let last = series.index_at(end_seconds).ok_or_else(|| { + RoutingError::new(RoutingErrorKind::InvalidInterval) + .with_detail("the series has no sample at the end of the interval") + })?; + if last > series.len() - 1 { + return Err(RoutingError::new(RoutingErrorKind::SeriesTooShort) + .with_detail("the interval extends past the series")); + } + + // Intersect the links of every sample spanned by the interval, taking + // the cost from the first sample that has each link. + let mut intersection = Topology::new(series.node_count()); + let reference = &series.samples()[first].topology; + for edge in reference.edges() { + let present_everywhere = (first..=last).all(|index| { + series.samples()[index] + .topology + .has_edge(edge.from, edge.to) + }); + if present_everywhere { + // The link was checked above, so a failure here would mean the + // reference topology contradicts itself. + let _ = intersection.add_edge(edge.from, edge.to, edge.cost); + } + } + + let route = shortest_path(&intersection, source, destination)?; + + // The leg is valid until the topology changes after the interval ends. + let end_of_validity = series + .interval_of(last) + .map_or(end_seconds, |(_, next)| next); + Ok(RouteLeg::new(route, start_seconds, end_of_validity)) +} diff --git a/crates/network/apsis-routing/src/error.rs b/crates/network/apsis-routing/src/error.rs new file mode 100644 index 0000000..437c422 --- /dev/null +++ b/crates/network/apsis-routing/src/error.rs @@ -0,0 +1,73 @@ +//! Errors reported while routing. + +use core::fmt; + +/// The category of a routing error. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum RoutingErrorKind { + /// A node index is outside the range of the graph. + NoSuchNode, + /// No route exists between the two nodes. + NoRoute, + /// The two endpoints are the same node. + SameEndpoint, + /// The interval is empty or not ordered. + InvalidInterval, + /// The topology series does not cover the requested interval. + SeriesTooShort, +} + +impl fmt::Display for RoutingErrorKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + Self::NoSuchNode => "no such node", + Self::NoRoute => "no route exists between these nodes", + Self::SameEndpoint => "the source and destination are the same node", + Self::InvalidInterval => "the interval is not usable", + Self::SeriesTooShort => "the topology series does not cover the interval", + }; + f.write_str(text) + } +} + +/// A failure while routing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoutingError { + kind: RoutingErrorKind, + detail: Option, +} + +impl RoutingError { + /// Creates an error with the given category. + pub fn new(kind: RoutingErrorKind) -> Self { + Self { kind, detail: None } + } + + /// Attaches a description of the values that caused the failure. + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + /// Returns the category of this failure. + pub fn kind(&self) -> RoutingErrorKind { + self.kind + } + + /// Returns the detail text attached to this error, if any. + pub fn detail(&self) -> Option<&str> { + self.detail.as_deref() + } +} + +impl fmt::Display for RoutingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.detail { + Some(detail) => write!(f, "{}: {detail}", self.kind), + None => write!(f, "{}", self.kind), + } + } +} + +impl core::error::Error for RoutingError {} diff --git a/crates/network/apsis-routing/src/lib.rs b/crates/network/apsis-routing/src/lib.rs index bd8646d..84659f2 100644 --- a/crates/network/apsis-routing/src/lib.rs +++ b/crates/network/apsis-routing/src/lib.rs @@ -1 +1,54 @@ //! Routing over time-varying satellite topologies. +//! +//! The topology of a satellite network changes as the satellites move, so a +//! route is only valid over an interval. This crate computes least cost +//! routes through a [`Topology`](apsis_topology::Topology) and reports the +//! route together with the time it remains valid. +//! +//! Two forms of routing are provided: +//! +//! - [`shortest_path`] finds the least cost route through the topology in +//! force at one instant. +//! - [`route_over_series`] finds a route that is valid for a whole interval, +//! by taking the intersection of the topologies across that interval. +//! +//! The second form is the one a store and forward network needs. A route +//! through a topology that changes halfway along it may not exist in the new +//! topology, so a route is only useful if its every link survives the +//! interval it is planned for. +//! +//! The algorithm is Dijkstra's, with a binary heap. Costs must be positive, +//! which the topology layer enforces, so the algorithm is exact: the first +//! time a node is removed from the heap its distance is final. +//! +//! Source: Dijkstra, E. W., *A note on two problems in connexion with +//! graphs*, Numerische Mathematik 1, 1959, and the binary heap formulation +//! as given in Cormen et al., *Introduction to Algorithms*, 3rd edition, +//! MIT Press, 2009, chapter 24. +//! +//! ``` +//! use apsis_routing::shortest_path; +//! use apsis_topology::Topology; +//! +//! // A three node line: 0 - 1 - 2, with the direct link 0 - 2 absent. +//! let mut topology = Topology::new(3); +//! topology.add_edge(0, 1, 1.0).expect("the nodes exist"); +//! topology.add_edge(1, 2, 1.0).expect("the nodes exist"); +//! +//! let route = shortest_path(&topology, 0, 2).expect("a route exists"); +//! assert_eq!(route.nodes, vec![0, 1, 2]); +//! assert!((route.cost - 2.0).abs() < 1e-12); +//! ``` + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +mod dijkstra; +mod error; +mod route; + +pub use dijkstra::{PathTree, route_over_series, shortest_path, shortest_paths_from}; +pub use error::{RoutingError, RoutingErrorKind}; +pub use route::{Route, RouteLeg, RouteStep}; + +pub use apsis_topology::{Topology, TopologySample, TopologySeries}; diff --git a/crates/network/apsis-routing/src/route.rs b/crates/network/apsis-routing/src/route.rs new file mode 100644 index 0000000..8acd612 --- /dev/null +++ b/crates/network/apsis-routing/src/route.rs @@ -0,0 +1,125 @@ +//! The route types. + +/// One hop of a route. +/// +/// A step records the link traversed and the cost of that link, so a caller +/// can attribute a route's total cost to its parts. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RouteStep { + /// The node the step leaves. + pub from: usize, + /// The node the step arrives at. + pub to: usize, + /// The cost of the link. + pub cost: f64, +} + +impl RouteStep { + /// Creates a step. + pub const fn new(from: usize, to: usize, cost: f64) -> Self { + Self { from, to, cost } + } +} + +/// A route between two nodes. +/// +/// The node list includes both endpoints, so a route of one hop has two +/// nodes and one step, and a route from a node to itself has one node and no +/// steps. +#[derive(Debug, Clone, PartialEq)] +pub struct Route { + /// The nodes in order, from the source to the destination. + pub nodes: Vec, + /// The links traversed, in order. + pub steps: Vec, + /// The total cost of the route. + pub cost: f64, +} + +impl Route { + /// Creates a route from its nodes and steps. + /// + /// The cost is taken as the sum of the step costs, so a caller cannot + /// make the total disagree with the parts. + pub fn new(nodes: Vec, steps: Vec) -> Self { + let cost = steps.iter().map(|step| step.cost).sum(); + Self { nodes, steps, cost } + } + + /// Returns the number of hops. + pub fn hop_count(&self) -> usize { + self.steps.len() + } + + /// Returns the source node. + /// + /// Returns `None` when the route has no nodes. + pub fn source(&self) -> Option { + self.nodes.first().copied() + } + + /// Returns the destination node. + /// + /// Returns `None` when the route has no nodes. + pub fn destination(&self) -> Option { + self.nodes.last().copied() + } + + /// Returns the route traversed in the opposite direction. + /// + /// The costs are unchanged, because a route reversed traverses the same + /// links. This is only correct when every link is present in both + /// directions with the same cost, which a caller must ensure. + pub fn reversed(&self) -> Self { + let nodes = self.nodes.iter().rev().copied().collect(); + let steps = self + .steps + .iter() + .rev() + .map(|step| RouteStep::new(step.to, step.from, step.cost)) + .collect(); + Self::new(nodes, steps) + } + + /// Returns `true` when the route passes through a node. + pub fn passes_through(&self, node: usize) -> bool { + self.nodes.contains(&node) + } + + /// Returns the position of a node in the route, if it is on it. + pub fn position_of(&self, node: usize) -> Option { + self.nodes.iter().position(|candidate| *candidate == node) + } +} + +/// A route with the interval over which it is valid. +#[derive(Debug, Clone, PartialEq)] +pub struct RouteLeg { + /// The route. + pub route: Route, + /// The time from which the route is valid, in seconds. + pub start_seconds: f64, + /// The time until which the route is valid, in seconds. + /// + /// This is the time of the topology change that invalidates the route. + /// It is infinite when the route is valid for all later times. + pub end_seconds: f64, +} + +impl RouteLeg { + /// Creates a route leg. + pub const fn new(route: Route, start_seconds: f64, end_seconds: f64) -> Self { + Self { + route, + start_seconds, + end_seconds, + } + } + + /// Returns the duration for which the route holds, in seconds. + /// + /// Returns infinity when the route is valid for all later times. + pub fn duration_seconds(&self) -> f64 { + self.end_seconds - self.start_seconds + } +} diff --git a/crates/network/apsis-routing/tests/dijkstra.rs b/crates/network/apsis-routing/tests/dijkstra.rs new file mode 100644 index 0000000..8c7de2f --- /dev/null +++ b/crates/network/apsis-routing/tests/dijkstra.rs @@ -0,0 +1,313 @@ +//! Tests for shortest path routing. + +use apsis_routing::{ + PathTree, Route, RouteStep, RoutingErrorKind, route_over_series, shortest_path, + shortest_paths_from, +}; +use apsis_topology::{Topology, TopologySample, TopologySeries}; + +/// A topology with the given directed edges and unit costs. +fn topology_with(node_count: usize, edges: &[(usize, usize)]) -> Topology { + let mut topology = Topology::new(node_count); + for &(from, to) in edges { + topology.add_edge(from, to, 1.0).expect("the nodes exist"); + } + topology +} + +#[test] +fn a_direct_link_is_the_shortest_route() { + let mut topology = Topology::new(3); + topology.add_edge(0, 1, 5.0).expect("valid"); + topology.add_edge(0, 2, 1.0).expect("valid"); + topology.add_edge(2, 1, 1.0).expect("valid"); + + // The direct link costs five, the two hop route costs two. + let route = shortest_path(&topology, 0, 1).expect("a route exists"); + assert_eq!(route.nodes, vec![0, 2, 1]); + assert!((route.cost - 2.0).abs() < 1e-12); + assert_eq!(route.hop_count(), 2); + assert_eq!(route.source(), Some(0)); + assert_eq!(route.destination(), Some(1)); +} + +#[test] +fn a_longer_route_in_hops_can_be_cheaper_in_cost() { + let mut topology = Topology::new(4); + // A cheap path of three hops against an expensive direct link. + topology.add_edge(0, 1, 1.0).expect("valid"); + topology.add_edge(1, 2, 1.0).expect("valid"); + topology.add_edge(2, 3, 1.0).expect("valid"); + topology.add_edge(0, 3, 10.0).expect("valid"); + + let route = shortest_path(&topology, 0, 3).expect("a route exists"); + assert_eq!(route.nodes, vec![0, 1, 2, 3]); + assert!((route.cost - 3.0).abs() < 1e-12); + assert_eq!(route.hop_count(), 3); +} + +#[test] +fn the_cost_is_the_sum_of_the_step_costs() { + let mut topology = Topology::new(3); + topology.add_edge(0, 1, 2.5).expect("valid"); + topology.add_edge(1, 2, 1.5).expect("valid"); + let route = shortest_path(&topology, 0, 2).expect("a route exists"); + assert!((route.cost - 4.0).abs() < 1e-12); + let summed: f64 = route.steps.iter().map(|step| step.cost).sum(); + assert!((route.cost - summed).abs() < 1e-12); +} + +#[test] +fn a_route_records_its_steps() { + let mut topology = Topology::new(3); + topology.add_edge(0, 1, 2.0).expect("valid"); + topology.add_edge(1, 2, 3.0).expect("valid"); + let route = shortest_path(&topology, 0, 2).expect("a route exists"); + assert_eq!(route.steps.len(), 2); + assert_eq!(route.steps[0], RouteStep::new(0, 1, 2.0)); + assert_eq!(route.steps[1], RouteStep::new(1, 2, 3.0)); +} + +#[test] +fn a_directed_topology_may_have_no_route() { + // The link only goes one way, so the reverse route does not exist. + let mut topology = Topology::new(2); + topology.add_edge(0, 1, 1.0).expect("valid"); + assert!(shortest_path(&topology, 0, 1).is_ok()); + let error = shortest_path(&topology, 1, 0).expect_err("no route"); + assert_eq!(error.kind(), RoutingErrorKind::NoRoute); +} + +#[test] +fn a_disconnected_destination_has_no_route() { + let mut topology = Topology::new(4); + topology.add_edge(0, 1, 1.0).expect("valid"); + topology.add_edge(2, 3, 1.0).expect("valid"); + let error = shortest_path(&topology, 0, 3).expect_err("no route"); + assert_eq!(error.kind(), RoutingErrorKind::NoRoute); +} + +#[test] +fn the_same_endpoint_is_rejected() { + let topology = topology_with(2, &[(0, 1)]); + let error = shortest_path(&topology, 0, 0).expect_err("the endpoints are equal"); + assert_eq!(error.kind(), RoutingErrorKind::SameEndpoint); +} + +#[test] +fn an_out_of_range_node_is_rejected() { + let topology = topology_with(2, &[(0, 1)]); + assert_eq!( + shortest_path(&topology, 0, 5) + .expect_err("no such node") + .kind(), + RoutingErrorKind::NoSuchNode + ); + assert_eq!( + shortest_paths_from(&topology, 5) + .expect_err("no such node") + .kind(), + RoutingErrorKind::NoSuchNode + ); +} + +#[test] +fn the_path_tree_covers_every_reachable_node() { + let mut topology = Topology::new(4); + topology.add_edge(0, 1, 1.0).expect("valid"); + topology.add_edge(1, 2, 1.0).expect("valid"); + // Node 3 is isolated. + let tree = shortest_paths_from(&topology, 0).expect("valid"); + assert_eq!(tree.source, 0); + assert_eq!(tree.distance(0), Some(0.0)); + assert_eq!(tree.distance(1), Some(1.0)); + assert_eq!(tree.distance(2), Some(2.0)); + assert_eq!(tree.distance(3), None); + assert!(tree.is_reachable(2)); + assert!(!tree.is_reachable(3)); + assert_eq!(tree.reachable_count(), 3); + assert_eq!(tree.distance(9), None); +} + +#[test] +fn the_path_tree_reconstructs_each_route() { + let mut topology = Topology::new(4); + topology.add_edge(0, 1, 1.0).expect("valid"); + topology.add_edge(1, 2, 1.0).expect("valid"); + topology.add_edge(2, 3, 1.0).expect("valid"); + let tree = shortest_paths_from(&topology, 0).expect("valid"); + let route = tree.route_to(3, &topology).expect("a route exists"); + assert_eq!(route.nodes, vec![0, 1, 2, 3]); + assert!((route.cost - 3.0).abs() < 1e-12); + + // The route to the source is the source alone. + let self_route = tree.route_to(0, &topology).expect("a route exists"); + assert_eq!(self_route.nodes, vec![0]); + assert_eq!(self_route.hop_count(), 0); +} + +#[test] +fn the_path_tree_rejects_an_unreachable_destination() { + let topology = topology_with(2, &[(0, 1)]); + let tree = shortest_paths_from(&topology, 1).expect("valid"); + let error = tree.route_to(0, &topology).expect_err("no route"); + assert_eq!(error.kind(), RoutingErrorKind::NoRoute); + let error = tree.route_to(9, &topology).expect_err("no such node"); + assert_eq!(error.kind(), RoutingErrorKind::NoSuchNode); +} + +#[test] +fn a_cheaper_route_replaces_a_more_expensive_one() { + // The heap must settle the nearer node first even when it is discovered + // second, which is the case that a naive breadth first search gets + // wrong. + let mut topology = Topology::new(4); + topology.add_edge(0, 1, 10.0).expect("valid"); + topology.add_edge(0, 2, 1.0).expect("valid"); + topology.add_edge(2, 3, 1.0).expect("valid"); + topology.add_edge(3, 1, 1.0).expect("valid"); + + let route = shortest_path(&topology, 0, 1).expect("a route exists"); + assert_eq!(route.nodes, vec![0, 2, 3, 1]); + assert!((route.cost - 3.0).abs() < 1e-12); +} + +#[test] +fn a_zero_cost_route_to_the_source_is_reported() { + let topology = topology_with(2, &[(0, 1)]); + let tree = shortest_paths_from(&topology, 0).expect("valid"); + assert_eq!(tree.distance(0), Some(0.0)); + assert_eq!(tree.predecessors[0], None); +} + +#[test] +fn a_route_can_be_reversed() { + let mut topology = Topology::new(3); + topology.add_bidirectional_edge(0, 1, 2.0).expect("valid"); + topology.add_bidirectional_edge(1, 2, 3.0).expect("valid"); + let route = shortest_path(&topology, 0, 2).expect("a route exists"); + let reversed = route.reversed(); + assert_eq!(reversed.nodes, vec![2, 1, 0]); + assert!((reversed.cost - route.cost).abs() < 1e-12); + assert_eq!(reversed.steps[0], RouteStep::new(2, 1, 3.0)); +} + +#[test] +fn a_route_reports_whether_it_passes_through_a_node() { + let mut topology = Topology::new(4); + topology.add_edge(0, 1, 1.0).expect("valid"); + topology.add_edge(1, 2, 1.0).expect("valid"); + let route = shortest_path(&topology, 0, 2).expect("a route exists"); + assert!(route.passes_through(1)); + assert!(!route.passes_through(3)); + assert_eq!(route.position_of(1), Some(1)); + assert_eq!(route.position_of(3), None); +} + +#[test] +fn an_empty_topology_has_no_routes() { + let topology = Topology::new(3); + let error = shortest_path(&topology, 0, 1).expect_err("no route"); + assert_eq!(error.kind(), RoutingErrorKind::NoRoute); + let tree = shortest_paths_from(&topology, 0).expect("valid"); + assert_eq!(tree.reachable_count(), 1); +} + +#[test] +fn the_route_type_is_constructible_directly() { + let route = Route::new(vec![0, 1], vec![RouteStep::new(0, 1, 2.0)]); + assert!((route.cost - 2.0).abs() < 1e-12); + assert_eq!(route.hop_count(), 1); + // An empty route has no endpoints. + let empty = Route::new(Vec::new(), Vec::new()); + assert_eq!(empty.source(), None); + assert_eq!(empty.destination(), None); +} + +#[test] +fn a_route_over_a_series_uses_the_links_present_throughout() { + // The link 1 to 2 exists at both ends of the interval but is absent in + // the middle, so no route through it survives the interval. The + // two hop route via node 3 is present throughout. + let series = TopologySeries::new(vec![ + TopologySample::new(0.0, topology_with(4, &[(0, 1), (1, 2), (0, 3), (3, 2)])), + TopologySample::new(10.0, topology_with(4, &[(0, 1), (0, 3), (3, 2)])), + TopologySample::new(20.0, topology_with(4, &[(0, 1), (1, 2), (0, 3), (3, 2)])), + ]) + .expect("the times increase"); + + let leg = route_over_series(&series, 0, 2, 0.0, 20.0).expect("a route exists"); + assert_eq!(leg.route.nodes, vec![0, 3, 2]); + assert!((leg.route.cost - 2.0).abs() < 1e-12); + // The leg is valid until the next change after the interval. + assert_eq!(leg.start_seconds, 0.0); + assert!(leg.end_seconds.is_infinite()); +} + +#[test] +fn a_route_over_a_series_reports_its_validity() { + let series = TopologySeries::new(vec![ + TopologySample::new(0.0, topology_with(2, &[(0, 1)])), + TopologySample::new(10.0, topology_with(2, &[(0, 1)])), + TopologySample::new(20.0, topology_with(2, &[])), + ]) + .expect("the times increase"); + + // A route planned over the first two samples is valid until the third. + let leg = route_over_series(&series, 0, 1, 0.0, 10.0).expect("a route exists"); + assert_eq!(leg.end_seconds, 20.0); + assert!((leg.duration_seconds() - 20.0).abs() < 1e-12); +} + +#[test] +fn a_route_over_a_series_fails_when_no_link_survives() { + // The only link is present at the start and absent at the end, so it + // does not survive the interval. + let series = TopologySeries::new(vec![ + TopologySample::new(0.0, topology_with(2, &[(0, 1)])), + TopologySample::new(10.0, topology_with(2, &[])), + ]) + .expect("the times increase"); + let error = route_over_series(&series, 0, 1, 0.0, 10.0).expect_err("no route"); + assert_eq!(error.kind(), RoutingErrorKind::NoRoute); +} + +#[test] +fn a_route_over_a_series_validates_its_arguments() { + let series = TopologySeries::new(vec![TopologySample::new(0.0, topology_with(2, &[(0, 1)]))]) + .expect("valid"); + + assert_eq!( + route_over_series(&series, 0, 1, 10.0, 0.0) + .expect_err("the interval runs backwards") + .kind(), + RoutingErrorKind::InvalidInterval + ); + assert_eq!( + route_over_series(&series, 0, 5, 0.0, 0.0) + .expect_err("no such node") + .kind(), + RoutingErrorKind::NoSuchNode + ); + assert_eq!( + route_over_series(&series, 0, 0, 0.0, 0.0) + .expect_err("the endpoints are equal") + .kind(), + RoutingErrorKind::SameEndpoint + ); + assert_eq!( + route_over_series(&series, 0, 1, f64::NAN, 0.0) + .expect_err("the interval is not finite") + .kind(), + RoutingErrorKind::InvalidInterval + ); +} + +#[test] +fn the_path_tree_type_is_public() { + // The type must be nameable by a caller that stores a routing table. + fn takes_tree(_tree: &PathTree) {} + let topology = topology_with(2, &[(0, 1)]); + let tree = shortest_paths_from(&topology, 0).expect("valid"); + takes_tree(&tree); +}