network: add time-varying topology graph and series
This commit is contained in:
parent
316a5f8eef
commit
97a4a82d0c
|
|
@ -295,6 +295,10 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "apsis-topology"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-math",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "apsis-units"
|
||||
|
|
|
|||
|
|
@ -11,3 +11,5 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-math.workspace = true
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
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<String>) -> 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 {}
|
||||
|
|
@ -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<Edge>,
|
||||
}
|
||||
|
||||
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<f64> {
|
||||
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<usize> {
|
||||
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<Vec<usize>> {
|
||||
(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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<TopologySample>,
|
||||
}
|
||||
|
||||
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<TopologySample>) -> Result<Self, TopologyError> {
|
||||
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<f64>, topologies: Vec<Topology>) -> Result<Self, TopologyError> {
|
||||
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<usize> {
|
||||
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<f64> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
Loading…
Reference in New Issue