From 97a4a82d0c9822aee6b60a2d768dd76b97718600 Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Sat, 26 Sep 2026 16:01:19 +0800 Subject: [PATCH] network: add time-varying topology graph and series --- Cargo.lock | 4 + crates/network/apsis-topology/Cargo.toml | 2 + crates/network/apsis-topology/src/error.rs | 79 ++++++ crates/network/apsis-topology/src/graph.rs | 242 ++++++++++++++++++ crates/network/apsis-topology/src/lib.rs | 49 +++- crates/network/apsis-topology/src/series.rs | 226 ++++++++++++++++ crates/network/apsis-topology/tests/graph.rs | 216 ++++++++++++++++ crates/network/apsis-topology/tests/series.rs | 223 ++++++++++++++++ 8 files changed, 1040 insertions(+), 1 deletion(-) create mode 100644 crates/network/apsis-topology/src/error.rs create mode 100644 crates/network/apsis-topology/src/graph.rs create mode 100644 crates/network/apsis-topology/src/series.rs create mode 100644 crates/network/apsis-topology/tests/graph.rs create mode 100644 crates/network/apsis-topology/tests/series.rs diff --git a/Cargo.lock b/Cargo.lock index c74c323..a9bcec9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,6 +295,10 @@ dependencies = [ [[package]] name = "apsis-topology" version = "0.1.0" +dependencies = [ + "apsis-core", + "apsis-math", +] [[package]] name = "apsis-units" diff --git a/crates/network/apsis-topology/Cargo.toml b/crates/network/apsis-topology/Cargo.toml index cfa2197..4efb559 100644 --- a/crates/network/apsis-topology/Cargo.toml +++ b/crates/network/apsis-topology/Cargo.toml @@ -11,3 +11,5 @@ publish = false workspace = true [dependencies] +apsis-core.workspace = true +apsis-math.workspace = true diff --git a/crates/network/apsis-topology/src/error.rs b/crates/network/apsis-topology/src/error.rs new file mode 100644 index 0000000..cf2e961 --- /dev/null +++ b/crates/network/apsis-topology/src/error.rs @@ -0,0 +1,79 @@ +//! Errors reported while building or querying a topology. + +use core::fmt; + +/// The category of a topology error. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum TopologyErrorKind { + /// A node index is outside the range of the graph. + NoSuchNode, + /// An edge already exists between the two nodes. + DuplicateEdge, + /// No edge exists between the two nodes. + NoSuchEdge, + /// A cost is not finite or not positive. + InvalidCost, + /// The series is empty. + EmptySeries, + /// The times in a series are not strictly increasing. + UnorderedTimes, + /// A time is not finite. + InvalidTime, +} + +impl fmt::Display for TopologyErrorKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + Self::NoSuchNode => "no such node", + Self::DuplicateEdge => "an edge already exists between these nodes", + Self::NoSuchEdge => "no edge exists between these nodes", + Self::InvalidCost => "the edge cost is not usable", + Self::EmptySeries => "the topology series is empty", + Self::UnorderedTimes => "the series times are not strictly increasing", + Self::InvalidTime => "the time is not finite", + }; + f.write_str(text) + } +} + +/// A failure while building or querying a topology. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TopologyError { + kind: TopologyErrorKind, + detail: Option, +} + +impl TopologyError { + /// Creates an error with the given category. + pub fn new(kind: TopologyErrorKind) -> 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) -> TopologyErrorKind { + 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 TopologyError { + 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 TopologyError {} diff --git a/crates/network/apsis-topology/src/graph.rs b/crates/network/apsis-topology/src/graph.rs new file mode 100644 index 0000000..cda1014 --- /dev/null +++ b/crates/network/apsis-topology/src/graph.rs @@ -0,0 +1,242 @@ +//! The topology graph. + +use crate::error::{TopologyError, TopologyErrorKind}; + +/// One link in a topology. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Edge { + /// The index of the first endpoint. + pub from: usize, + /// The index of the second endpoint. + pub to: usize, + /// The cost of traversing the link. + /// + /// The meaning is the caller's: a delay in seconds, a hop count, or any + /// other measure a routing algorithm minimises. It must be finite and + /// positive, so that adding edges never shortens a path. + pub cost: f64, +} + +impl Edge { + /// Creates an edge. + pub const fn new(from: usize, to: usize, cost: f64) -> Self { + Self { from, to, cost } + } + + /// Returns the same edge with its endpoints exchanged. + /// + /// A link between two satellites carries traffic both ways, so most + /// topologies add both directions. + pub const fn reversed(self) -> Self { + Self { + from: self.to, + to: self.from, + cost: self.cost, + } + } +} + +/// A graph of nodes and links at one instant. +/// +/// The node count is fixed at construction and nodes are identified by their +/// index. Edges are directed: adding an edge from one node to another does +/// not add the reverse, so a caller that wants a bidirectional link adds +/// both directions. This is deliberate, because an inter-satellite link may +/// have different capacity in each direction and a ground link is often +/// asymmetric. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Topology { + node_count: usize, + edges: Vec, +} + +impl Topology { + /// Creates an empty topology with the given number of nodes. + pub const fn new(node_count: usize) -> Self { + Self { + node_count, + edges: Vec::new(), + } + } + + /// Returns the number of nodes. + pub const fn node_count(&self) -> usize { + self.node_count + } + + /// Returns the number of edges. + pub fn edge_count(&self) -> usize { + self.edges.len() + } + + /// Returns `true` when there are no edges. + pub fn is_empty(&self) -> bool { + self.edges.is_empty() + } + + /// Returns the edges. + pub fn edges(&self) -> &[Edge] { + &self.edges + } + + /// Adds a directed edge. + /// + /// Returns an error when either node index is out of range, when the + /// cost is not finite and positive, or when the edge already exists. + /// Rejecting a duplicate rather than replacing it keeps a topology free + /// of silent contradictions about the cost of a link. + pub fn add_edge(&mut self, from: usize, to: usize, cost: f64) -> Result<(), TopologyError> { + if from >= self.node_count || to >= self.node_count { + return Err(TopologyError::new(TopologyErrorKind::NoSuchNode) + .with_detail(format!("nodes {from} and {to} of {}", self.node_count))); + } + if !cost.is_finite() || cost <= 0.0 { + return Err(TopologyError::new(TopologyErrorKind::InvalidCost) + .with_detail(format!("cost {cost}"))); + } + if self.has_edge(from, to) { + return Err(TopologyError::new(TopologyErrorKind::DuplicateEdge) + .with_detail(format!("edge {from} to {to}"))); + } + self.edges.push(Edge::new(from, to, cost)); + Ok(()) + } + + /// Adds an edge in both directions with the same cost. + /// + /// If the second direction fails after the first succeeds, the first is + /// rolled back, so the topology is left unchanged when the call reports + /// an error. + pub fn add_bidirectional_edge( + &mut self, + first: usize, + second: usize, + cost: f64, + ) -> Result<(), TopologyError> { + self.add_edge(first, second, cost)?; + if let Err(error) = self.add_edge(second, first, cost) { + // Remove the first direction so the topology is unchanged. + self.edges + .retain(|edge| !(edge.from == first && edge.to == second)); + return Err(error); + } + Ok(()) + } + + /// Returns `true` when an edge exists from one node to another. + pub fn has_edge(&self, from: usize, to: usize) -> bool { + self.edges + .iter() + .any(|edge| edge.from == from && edge.to == to) + } + + /// Returns the cost of the edge between two nodes. + /// + /// Returns `None` when there is no such edge. + pub fn cost(&self, from: usize, to: usize) -> Option { + self.edges + .iter() + .find(|edge| edge.from == from && edge.to == to) + .map(|edge| edge.cost) + } + + /// Removes the edge between two nodes. + /// + /// Returns an error when there is no such edge. + pub fn remove_edge(&mut self, from: usize, to: usize) -> Result<(), TopologyError> { + let before = self.edges.len(); + self.edges + .retain(|edge| !(edge.from == from && edge.to == to)); + if self.edges.len() == before { + return Err(TopologyError::new(TopologyErrorKind::NoSuchEdge) + .with_detail(format!("edge {from} to {to}"))); + } + Ok(()) + } + + /// Returns the indices of the nodes reachable from a node in one hop. + /// + /// The result is in the order the edges were added, so it is + /// deterministic for a given construction sequence. + pub fn neighbours(&self, node: usize) -> Vec { + self.edges + .iter() + .filter(|edge| edge.from == node) + .map(|edge| edge.to) + .collect() + } + + /// Returns the number of edges leaving a node. + pub fn out_degree(&self, node: usize) -> usize { + self.edges.iter().filter(|edge| edge.from == node).count() + } + + /// Returns the number of edges arriving at a node. + pub fn in_degree(&self, node: usize) -> usize { + self.edges.iter().filter(|edge| edge.to == node).count() + } + + /// Returns the adjacency of every node. + /// + /// The result has one entry per node, in node order, each listing the + /// neighbours of that node. + pub fn adjacency(&self) -> Vec> { + (0..self.node_count) + .map(|node| self.neighbours(node)) + .collect() + } + + /// Returns the number of connected components, treating the edges as + /// undirected. + /// + /// This answers how many separate networks the topology forms, which is + /// the first thing to check about a constellation snapshot: a + /// constellation that has split into pieces can still carry traffic + /// within each piece but not between them. + pub fn connected_components(&self) -> usize { + if self.node_count == 0 { + return 0; + } + let mut visited = vec![false; self.node_count]; + let mut components = 0; + for start in 0..self.node_count { + if visited[start] { + continue; + } + components += 1; + // A breadth first walk over the edges in both directions. + let mut stack = vec![start]; + visited[start] = true; + while let Some(node) = stack.pop() { + for edge in &self.edges { + let neighbour = if edge.from == node { + edge.to + } else if edge.to == node { + edge.from + } else { + continue; + }; + if !visited[neighbour] { + visited[neighbour] = true; + stack.push(neighbour); + } + } + } + } + components + } + + /// Returns `true` when every node can reach every other node, treating + /// the edges as undirected. + pub fn is_connected(&self) -> bool { + self.node_count <= 1 || self.connected_components() == 1 + } + + /// Returns the total cost of every edge. + /// + /// This is a crude measure of the network's extent, useful for comparing + /// snapshots rather than for routing. + pub fn total_cost(&self) -> f64 { + self.edges.iter().map(|edge| edge.cost).sum() + } +} diff --git a/crates/network/apsis-topology/src/lib.rs b/crates/network/apsis-topology/src/lib.rs index c3c8600..2855d34 100644 --- a/crates/network/apsis-topology/src/lib.rs +++ b/crates/network/apsis-topology/src/lib.rs @@ -1 +1,48 @@ -//! Time-varying network topology modeling. +//! Time-varying network topology. +//! +//! A satellite network is not a single graph. The set of usable links +//! changes as the satellites move, so the network is a sequence of graphs +//! indexed by time, and this crate provides the data structures for that +//! sequence. +//! +//! The model is deliberately simple. A [`Topology`] is a graph with a fixed +//! set of nodes, which are satellites or ground stations, and a set of edges +//! that may be present or absent. A [`TopologySeries`] is a sequence of +//! topologies at increasing times, together with the interval each one +//! applies to. +//! +//! The series is a step function: a topology holds from its own time until +//! the time of the next entry. That is the standard discrete time model for +//! a time-varying graph, and it is what routing over the series expects. +//! +//! Edges carry a cost as well as their presence, so that a routing algorithm +//! has something to minimise. The cost is a plain number, so a caller may +//! put a delay, a hop count, or anything else in it, as long as the routing +//! algorithm and the cost agree on the meaning. +//! +//! ``` +//! use apsis_topology::{Topology, TopologySample, TopologySeries}; +//! +//! // Two satellites linked to each other. +//! let mut topology = Topology::new(2); +//! topology.add_edge(0, 1, 1.0).expect("the nodes exist"); +//! assert!(topology.has_edge(0, 1)); +//! +//! let series = TopologySeries::new(vec![TopologySample::new(0.0, topology)]) +//! .expect("the series is not empty"); +//! assert_eq!(series.len(), 1); +//! assert!(series.topology_at(0.0).expect("a topology exists").has_edge(0, 1)); +//! ``` + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +mod error; +mod graph; +mod series; + +pub use error::{TopologyError, TopologyErrorKind}; +pub use graph::{Edge, Topology}; +pub use series::{TopologySample, TopologySeries}; + +pub use apsis_math::Vec3; diff --git a/crates/network/apsis-topology/src/series.rs b/crates/network/apsis-topology/src/series.rs new file mode 100644 index 0000000..c5039fe --- /dev/null +++ b/crates/network/apsis-topology/src/series.rs @@ -0,0 +1,226 @@ +//! The time series of topologies. + +use crate::error::{TopologyError, TopologyErrorKind}; +use crate::graph::Topology; + +/// One topology and the time from which it applies. +#[derive(Debug, Clone, PartialEq)] +pub struct TopologySample { + /// The time from which this topology applies, in seconds from the + /// caller's origin. + pub seconds: f64, + /// The topology in force from that time. + pub topology: Topology, +} + +impl TopologySample { + /// Creates a sample. + pub const fn new(seconds: f64, topology: Topology) -> Self { + Self { seconds, topology } + } +} + +/// A sequence of topologies over time. +/// +/// The sequence is a step function. The entry at index `i` applies from its +/// own time until the time of the entry at index `i + 1`, and the last entry +/// applies for all later times. Querying a time before the first entry +/// returns the first topology, so the series behaves as if it extends +/// backwards as well as forwards. +/// +/// The times must be strictly increasing. Two entries at the same time would +/// make the topology at that instant ambiguous. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct TopologySeries { + samples: Vec, +} + +impl TopologySeries { + /// Creates a series from a list of samples. + /// + /// Returns an error when the list is empty, when a time is not finite, + /// or when the times are not strictly increasing. + pub fn new(samples: Vec) -> Result { + if samples.is_empty() { + return Err(TopologyError::new(TopologyErrorKind::EmptySeries)); + } + let mut previous = f64::NEG_INFINITY; + for (index, sample) in samples.iter().enumerate() { + if !sample.seconds.is_finite() { + return Err(TopologyError::new(TopologyErrorKind::InvalidTime) + .with_detail(format!("entry {index} has time {}", sample.seconds))); + } + if sample.seconds <= previous { + return Err( + TopologyError::new(TopologyErrorKind::UnorderedTimes).with_detail(format!( + "entry {index} at {} does not follow {previous}", + sample.seconds + )), + ); + } + previous = sample.seconds; + } + Ok(Self { samples }) + } + + /// Creates a series from times and topologies as separate lists. + /// + /// The two lists must have the same length. + /// + /// Returns an error when the lists differ in length or when the series + /// itself is not valid. + pub fn from_parts(times: Vec, topologies: Vec) -> Result { + if times.len() != topologies.len() { + return Err( + TopologyError::new(TopologyErrorKind::EmptySeries).with_detail(format!( + "{} times against {} topologies", + times.len(), + topologies.len() + )), + ); + } + let samples = times + .into_iter() + .zip(topologies) + .map(|(seconds, topology)| TopologySample::new(seconds, topology)) + .collect(); + Self::new(samples) + } + + /// Returns the samples. + pub fn samples(&self) -> &[TopologySample] { + &self.samples + } + + /// Returns the number of samples. + pub fn len(&self) -> usize { + self.samples.len() + } + + /// Returns `true` when there are no samples. + /// + /// A series built through either constructor is never empty, so this + /// always returns `false`; it exists because a collection type should + /// offer it alongside [`TopologySeries::len`]. + pub fn is_empty(&self) -> bool { + self.samples.is_empty() + } + + /// Returns the first time in the series. + pub fn start_seconds(&self) -> f64 { + self.samples.first().map_or(0.0, |sample| sample.seconds) + } + + /// Returns the last time in the series. + pub fn end_seconds(&self) -> f64 { + self.samples.last().map_or(0.0, |sample| sample.seconds) + } + + /// Returns the index of the sample that applies at a time. + /// + /// The index is the last entry whose time is at or before the query, so + /// a time before the series returns zero and a time after it returns the + /// last index. + /// + /// Returns `None` when the time is not finite or the series is empty. + pub fn index_at(&self, seconds: f64) -> Option { + if !seconds.is_finite() || self.samples.is_empty() { + return None; + } + // The samples are ordered, so a binary search gives the insertion + // point of the query, and the entry before it is the one in force. + match self + .samples + .binary_search_by(|sample| sample.seconds.partial_cmp(&seconds).expect("finite times")) + { + Ok(exact) => Some(exact), + Err(0) => Some(0), + Err(insert) => Some(insert - 1), + } + } + + /// Returns the topology in force at a time. + /// + /// Returns `None` when the time is not finite or the series is empty. + pub fn topology_at(&self, seconds: f64) -> Option<&Topology> { + Some(&self.samples.get(self.index_at(seconds)?)?.topology) + } + + /// Returns the interval over which the sample at an index applies. + /// + /// The upper bound is the time of the next sample, or positive infinity + /// for the last entry. + /// + /// Returns `None` when the index is out of range. + pub fn interval_of(&self, index: usize) -> Option<(f64, f64)> { + let sample = self.samples.get(index)?; + let end = self + .samples + .get(index + 1) + .map_or(f64::INFINITY, |next| next.seconds); + Some((sample.seconds, end)) + } + + /// Returns the number of times the topology changes. + /// + /// This is the number of samples less one, since each change introduces + /// a new entry. It measures how dynamic the network is: a series with + /// many changes needs routing that accounts for them. + pub fn change_count(&self) -> usize { + self.samples.len().saturating_sub(1) + } + + /// Returns the mean interval between changes, in seconds. + /// + /// The last entry has unbounded duration, so it is excluded from the + /// mean and the result covers the changes that have a measured interval. + /// + /// Returns `None` when there is only one sample. + pub fn mean_interval_seconds(&self) -> Option { + if self.samples.len() < 2 { + return None; + } + let span = self.end_seconds() - self.start_seconds(); + Some(span / self.change_count() as f64) + } + + /// Returns the number of edge changes across the series. + /// + /// Each topology is compared with the one before it, counting the edges + /// that appear and the edges that disappear. A topology that differs by + /// one link contributes two, since one link changed. + /// + /// Returns zero for a series of one sample. + pub fn edge_changes(&self) -> usize { + let mut changes = 0; + for pair in self.samples.windows(2) { + let before = &pair[0].topology; + let after = &pair[1].topology; + // Edges present before but not after. + changes += before + .edges() + .iter() + .filter(|edge| !after.has_edge(edge.from, edge.to)) + .count(); + // Edges present after but not before. + changes += after + .edges() + .iter() + .filter(|edge| !before.has_edge(edge.from, edge.to)) + .count(); + } + changes + } + + /// Returns the number of nodes in the series. + /// + /// Every entry is expected to describe the same set of nodes, so the + /// value is taken from the first entry. A caller that changes the node + /// count between entries is comparing two different networks and should + /// build two series instead. + pub fn node_count(&self) -> usize { + self.samples + .first() + .map_or(0, |sample| sample.topology.node_count()) + } +} diff --git a/crates/network/apsis-topology/tests/graph.rs b/crates/network/apsis-topology/tests/graph.rs new file mode 100644 index 0000000..e379e00 --- /dev/null +++ b/crates/network/apsis-topology/tests/graph.rs @@ -0,0 +1,216 @@ +//! Tests for the topology graph. + +use apsis_topology::{Edge, Topology, TopologyErrorKind}; + +/// A four node ring, with links both ways. +fn ring() -> Topology { + let mut topology = Topology::new(4); + for node in 0..4 { + topology + .add_bidirectional_edge(node, (node + 1) % 4, 1.0) + .expect("the nodes exist"); + } + topology +} + +#[test] +fn an_empty_topology_has_no_edges() { + let topology = Topology::new(5); + assert_eq!(topology.node_count(), 5); + assert_eq!(topology.edge_count(), 0); + assert!(topology.is_empty()); + assert_eq!(topology.total_cost(), 0.0); + assert!(!topology.has_edge(0, 1)); + assert_eq!(topology.cost(0, 1), None); + assert!(topology.neighbours(0).is_empty()); +} + +#[test] +fn an_edge_is_directed() { + let mut topology = Topology::new(2); + topology.add_edge(0, 1, 2.5).expect("valid"); + assert!(topology.has_edge(0, 1)); + assert!(!topology.has_edge(1, 0)); + assert_eq!(topology.cost(0, 1), Some(2.5)); + assert_eq!(topology.out_degree(0), 1); + assert_eq!(topology.in_degree(0), 0); + assert_eq!(topology.out_degree(1), 0); + assert_eq!(topology.in_degree(1), 1); +} + +#[test] +fn a_bidirectional_edge_adds_both_directions() { + let mut topology = Topology::new(2); + topology.add_bidirectional_edge(0, 1, 3.0).expect("valid"); + assert!(topology.has_edge(0, 1)); + assert!(topology.has_edge(1, 0)); + assert_eq!(topology.edge_count(), 2); + assert_eq!(topology.cost(0, 1), topology.cost(1, 0)); +} + +#[test] +fn an_out_of_range_node_is_rejected() { + let mut topology = Topology::new(2); + let error = topology + .add_edge(0, 2, 1.0) + .expect_err("node 2 does not exist"); + assert_eq!(error.kind(), TopologyErrorKind::NoSuchNode); + assert_eq!( + topology.add_edge(9, 0, 1.0).expect_err("node 9").kind(), + TopologyErrorKind::NoSuchNode + ); + // The topology is unchanged. + assert!(topology.is_empty()); +} + +#[test] +fn an_invalid_cost_is_rejected() { + let mut topology = Topology::new(2); + for cost in [0.0, -1.0, f64::NAN, f64::INFINITY] { + let error = topology + .add_edge(0, 1, cost) + .expect_err("the cost is not usable"); + assert_eq!(error.kind(), TopologyErrorKind::InvalidCost, "cost {cost}"); + } + assert!(topology.is_empty()); +} + +#[test] +fn a_duplicate_edge_is_rejected() { + let mut topology = Topology::new(2); + topology.add_edge(0, 1, 1.0).expect("valid"); + let error = topology + .add_edge(0, 1, 2.0) + .expect_err("the edge already exists"); + assert_eq!(error.kind(), TopologyErrorKind::DuplicateEdge); + // The original cost is retained. + assert_eq!(topology.cost(0, 1), Some(1.0)); + assert_eq!(topology.edge_count(), 1); +} + +#[test] +fn a_self_loop_is_allowed_and_counted() { + // A self loop is unusual in a satellite network but is not an error: + // a node may have a local interface, and the graph need not forbid it. + let mut topology = Topology::new(1); + topology.add_edge(0, 0, 1.0).expect("valid"); + assert!(topology.has_edge(0, 0)); + assert_eq!(topology.out_degree(0), 1); + assert_eq!(topology.in_degree(0), 1); + assert_eq!(topology.connected_components(), 1); +} + +#[test] +fn an_edge_can_be_removed() { + let mut topology = Topology::new(2); + topology.add_edge(0, 1, 1.0).expect("valid"); + topology.remove_edge(0, 1).expect("the edge exists"); + assert!(!topology.has_edge(0, 1)); + assert!(topology.is_empty()); + + let error = topology.remove_edge(0, 1).expect_err("the edge is gone"); + assert_eq!(error.kind(), TopologyErrorKind::NoSuchEdge); +} + +#[test] +fn the_adjacency_lists_every_node() { + let topology = ring(); + let adjacency = topology.adjacency(); + assert_eq!(adjacency.len(), 4); + // Each node of a ring has two neighbours in each direction; the + // bidirectional construction gives two outgoing edges. + for neighbours in &adjacency { + assert_eq!(neighbours.len(), 2); + } + // The order follows the insertion order: node 0 gains its edge to 1 + // when node 0 is processed, and its edge to 3 when node 3 is. + assert_eq!(adjacency[0], vec![1, 3]); + assert_eq!(adjacency[1], vec![0, 2]); +} + +#[test] +fn the_ring_is_connected() { + let topology = ring(); + assert!(topology.is_connected()); + assert_eq!(topology.connected_components(), 1); +} + +#[test] +fn removing_a_link_splits_the_ring() { + let mut topology = ring(); + // Removing one direction of each of two opposite links breaks the ring + // into two paths. + topology.remove_edge(0, 1).expect("the edge exists"); + topology.remove_edge(1, 0).expect("the edge exists"); + topology.remove_edge(2, 3).expect("the edge exists"); + topology.remove_edge(3, 2).expect("the edge exists"); + assert_eq!(topology.connected_components(), 2); + assert!(!topology.is_connected()); +} + +#[test] +fn a_single_direction_is_enough_to_connect_components() { + // The component count treats edges as undirected, so a link that only + // carries traffic one way still joins two pieces of the graph. + let mut topology = Topology::new(2); + topology.add_edge(0, 1, 1.0).expect("valid"); + assert_eq!(topology.connected_components(), 1); + + // But the reverse direction does not exist, so the node cannot be + // reached from the other side. + assert!(!topology.has_edge(1, 0)); +} + +#[test] +fn isolated_nodes_are_each_a_component() { + let topology = Topology::new(4); + assert_eq!(topology.connected_components(), 4); + // A graph with no edges is still connected when it has at most one + // node, and is not when it has more. + assert!(!topology.is_connected()); + assert!(Topology::new(1).is_connected()); + assert!(Topology::new(0).is_connected()); + assert_eq!(Topology::new(0).connected_components(), 0); +} + +#[test] +fn the_total_cost_sums_the_edges() { + let mut topology = Topology::new(3); + topology.add_edge(0, 1, 1.5).expect("valid"); + topology.add_edge(1, 2, 2.5).expect("valid"); + assert!((topology.total_cost() - 4.0).abs() < 1e-12); +} + +#[test] +fn an_edge_can_be_reversed() { + let edge = Edge::new(3, 7, 2.0); + let reversed = edge.reversed(); + assert_eq!(reversed.from, 7); + assert_eq!(reversed.to, 3); + assert_eq!(reversed.cost, 2.0); +} + +#[test] +fn a_failed_bidirectional_add_leaves_the_topology_unchanged() { + let mut topology = Topology::new(2); + // The first direction succeeds, but the second is a duplicate, so the + // whole call must fail and roll back. + topology.add_edge(1, 0, 5.0).expect("valid"); + let error = topology + .add_bidirectional_edge(0, 1, 2.0) + .expect_err("the reverse edge exists"); + assert_eq!(error.kind(), TopologyErrorKind::DuplicateEdge); + // The forward direction that was added must have been removed. + assert!(!topology.has_edge(0, 1)); + assert!(topology.has_edge(1, 0)); + assert_eq!(topology.edge_count(), 1); +} + +#[test] +fn the_neighbour_order_follows_the_edge_insertion_order() { + let mut topology = Topology::new(4); + topology.add_edge(0, 2, 1.0).expect("valid"); + topology.add_edge(0, 1, 1.0).expect("valid"); + topology.add_edge(0, 3, 1.0).expect("valid"); + assert_eq!(topology.neighbours(0), vec![2, 1, 3]); +} diff --git a/crates/network/apsis-topology/tests/series.rs b/crates/network/apsis-topology/tests/series.rs new file mode 100644 index 0000000..2d61b90 --- /dev/null +++ b/crates/network/apsis-topology/tests/series.rs @@ -0,0 +1,223 @@ +//! Tests for the topology time series. + +use apsis_topology::{Topology, TopologyErrorKind, TopologySample, TopologySeries}; + +/// A topology with the given directed edges, all of unit cost. +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 +} + +/// A series of three topologies at 0, 10, and 25 seconds. +fn series() -> TopologySeries { + TopologySeries::new(vec![ + TopologySample::new(0.0, topology_with(3, &[(0, 1)])), + TopologySample::new(10.0, topology_with(3, &[(0, 1), (1, 2)])), + TopologySample::new(25.0, topology_with(3, &[(0, 2)])), + ]) + .expect("the times increase") +} + +#[test] +fn a_series_holds_its_samples() { + let series = series(); + assert_eq!(series.len(), 3); + assert!(!series.is_empty()); + assert_eq!(series.start_seconds(), 0.0); + assert_eq!(series.end_seconds(), 25.0); + assert_eq!(series.node_count(), 3); +} + +#[test] +fn a_time_selects_the_sample_in_force() { + let series = series(); + // At the exact times, the entry itself applies. + assert_eq!(series.index_at(0.0), Some(0)); + assert_eq!(series.index_at(10.0), Some(1)); + assert_eq!(series.index_at(25.0), Some(2)); + + // Between entries, the earlier one applies. + assert_eq!(series.index_at(5.0), Some(0)); + assert_eq!(series.index_at(9.999), Some(0)); + assert_eq!(series.index_at(10.001), Some(1)); + assert_eq!(series.index_at(24.999), Some(1)); + + // A time after the last entry keeps the last topology. + assert_eq!(series.index_at(1_000.0), Some(2)); + // A time before the first entry uses the first topology. + assert_eq!(series.index_at(-50.0), Some(0)); +} + +#[test] +fn the_topology_in_force_reflects_the_changes() { + let series = series(); + // At one second only the first link exists. + let early = series.topology_at(1.0).expect("a topology exists"); + assert!(early.has_edge(0, 1)); + assert!(!early.has_edge(1, 2)); + + // At fifteen seconds the second link has been added. + let middle = series.topology_at(15.0).expect("a topology exists"); + assert!(middle.has_edge(0, 1)); + assert!(middle.has_edge(1, 2)); + + // At thirty seconds the topology has changed again. + let late = series.topology_at(30.0).expect("a topology exists"); + assert!(!late.has_edge(0, 1)); + assert!(late.has_edge(0, 2)); +} + +#[test] +fn the_interval_of_a_sample_is_bounded_by_the_next() { + let series = series(); + assert_eq!(series.interval_of(0), Some((0.0, 10.0))); + assert_eq!(series.interval_of(1), Some((10.0, 25.0))); + // The last interval is unbounded. + let (start, end) = series.interval_of(2).expect("the index exists"); + assert_eq!(start, 25.0); + assert!(end.is_infinite()); + assert_eq!(series.interval_of(3), None); +} + +#[test] +fn the_change_count_excludes_the_first_entry() { + let series = series(); + assert_eq!(series.change_count(), 2); + assert_eq!(series.mean_interval_seconds(), Some(12.5)); +} + +#[test] +fn the_edge_changes_count_appearances_and_disappearances() { + let series = series(); + // From the first to the second topology, the edge 1 to 2 appears, one + // change. From the second to the third, the edges 0 to 1 and 1 to 2 + // disappear and 0 to 2 appears, three changes. + assert_eq!(series.edge_changes(), 4); +} + +#[test] +fn a_single_sample_series_has_no_changes() { + let single = TopologySeries::new(vec![TopologySample::new(0.0, topology_with(2, &[(0, 1)]))]) + .expect("valid"); + assert_eq!(single.len(), 1); + assert_eq!(single.change_count(), 0); + assert_eq!(single.mean_interval_seconds(), None); + assert_eq!(single.edge_changes(), 0); +} + +#[test] +fn an_empty_series_is_rejected() { + let error = TopologySeries::new(Vec::new()).expect_err("no samples"); + assert_eq!(error.kind(), TopologyErrorKind::EmptySeries); +} + +#[test] +fn a_non_finite_time_is_rejected() { + for time in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let error = TopologySeries::new(vec![TopologySample::new(time, Topology::new(1))]) + .expect_err("the time is not finite"); + assert_eq!(error.kind(), TopologyErrorKind::InvalidTime, "time {time}"); + } +} + +#[test] +fn unordered_times_are_rejected() { + let error = TopologySeries::new(vec![ + TopologySample::new(10.0, Topology::new(1)), + TopologySample::new(5.0, Topology::new(1)), + ]) + .expect_err("the times decrease"); + assert_eq!(error.kind(), TopologyErrorKind::UnorderedTimes); + + // Equal times are also rejected, because the topology at that instant + // would be ambiguous. + let error = TopologySeries::new(vec![ + TopologySample::new(10.0, Topology::new(1)), + TopologySample::new(10.0, Topology::new(1)), + ]) + .expect_err("the times are equal"); + assert_eq!(error.kind(), TopologyErrorKind::UnorderedTimes); +} + +#[test] +fn a_series_can_be_built_from_separate_lists() { + let series = TopologySeries::from_parts( + vec![0.0, 10.0], + vec![topology_with(2, &[(0, 1)]), topology_with(2, &[])], + ) + .expect("the lists match"); + assert_eq!(series.len(), 2); + assert!( + series + .topology_at(5.0) + .expect("a topology exists") + .has_edge(0, 1) + ); + assert!( + !series + .topology_at(15.0) + .expect("a topology exists") + .has_edge(0, 1) + ); +} + +#[test] +fn mismatched_list_lengths_are_rejected() { + let error = TopologySeries::from_parts(vec![0.0, 10.0], vec![topology_with(2, &[(0, 1)])]) + .expect_err("the lengths differ"); + assert_eq!(error.kind(), TopologyErrorKind::EmptySeries); +} + +#[test] +fn a_non_finite_query_time_is_rejected() { + let series = series(); + assert_eq!(series.index_at(f64::NAN), None); + assert_eq!(series.index_at(f64::INFINITY), None); + assert_eq!(series.topology_at(f64::NAN), None); +} + +#[test] +fn a_series_with_negative_times_is_accepted() { + // The origin of the time axis is the caller's choice, so negative times + // are as valid as positive ones. + let series = TopologySeries::new(vec![ + TopologySample::new(-100.0, topology_with(2, &[(0, 1)])), + TopologySample::new(-50.0, topology_with(2, &[])), + ]) + .expect("the times increase"); + assert_eq!(series.start_seconds(), -100.0); + assert_eq!(series.end_seconds(), -50.0); + assert!( + series + .topology_at(-75.0) + .expect("a topology exists") + .has_edge(0, 1) + ); + assert!( + !series + .topology_at(0.0) + .expect("a topology exists") + .has_edge(0, 1) + ); +} + +#[test] +fn the_samples_are_accessible() { + let series = series(); + assert_eq!(series.samples().len(), 3); + assert_eq!(series.samples()[0].seconds, 0.0); + assert_eq!(series.samples()[1].seconds, 10.0); +} + +#[test] +fn a_change_at_the_boundary_is_immediate() { + // The topology changes exactly at the recorded time, not after it. + let series = series(); + let before = series.topology_at(10.0 - 1e-9).expect("a topology exists"); + let after = series.topology_at(10.0).expect("a topology exists"); + assert!(!before.has_edge(1, 2)); + assert!(after.has_edge(1, 2)); +}