network: add time-varying topology graph and series

This commit is contained in:
ApsisRS contributors 2026-09-26 16:01:19 +08:00
parent 016c3a4b76
commit 84e62abe52
8 changed files with 1040 additions and 1 deletions

4
Cargo.lock generated
View File

@ -295,6 +295,10 @@ dependencies = [
[[package]]
name = "apsis-topology"
version = "0.1.0"
dependencies = [
"apsis-core",
"apsis-math",
]
[[package]]
name = "apsis-units"

View File

@ -11,3 +11,5 @@ publish = false
workspace = true
[dependencies]
apsis-core.workspace = true
apsis-math.workspace = true

View File

@ -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 {}

View File

@ -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()
}
}

View File

@ -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;

View File

@ -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())
}
}

View File

@ -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]);
}

View File

@ -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));
}