apsis-rs/crates/network/apsis-topology/src/graph.rs

281 lines
9.7 KiB
Rust

//! 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>,
/// The positions in `edges` of the edges leaving each node.
///
/// The index is what makes membership a constant time test. Without it,
/// asking whether an edge already exists scans the whole edge list, and
/// building a graph of `E` edges costs `O(E^2)`, which turns the
/// visibility sweep of a constellation, quadratic in the number of
/// satellites, into cubic work. The index holds positions rather than
/// destinations so that `remove_edge`, which invalidates positions, can
/// repair it in one pass.
outgoing: Vec<Vec<usize>>,
}
impl Topology {
/// Creates an empty topology with the given number of nodes.
///
/// This is not a `const fn` because it sizes the adjacency index, which
/// allocates.
pub fn new(node_count: usize) -> Self {
let mut outgoing = Vec::new();
outgoing.resize_with(node_count, Vec::new);
Self {
node_count,
edges: Vec::new(),
outgoing,
}
}
/// 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.outgoing[from].push(self.edges.len());
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.remove_edge_unchecked(first, 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.outgoing.get(from).is_some_and(|positions| {
positions
.iter()
.any(|&position| self.edges[position].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> {
let positions = self.outgoing.get(from)?;
positions
.iter()
.find(|&&position| self.edges[position].to == to)
.map(|&position| self.edges[position].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> {
if !self.has_edge(from, to) {
return Err(TopologyError::new(TopologyErrorKind::NoSuchEdge)
.with_detail(format!("edge {from} to {to}")));
}
self.remove_edge_unchecked(from, to);
Ok(())
}
/// Removes an edge the caller has already established exists.
///
/// Removing an edge renumbers every position after it, so the index is
/// rebuilt from the surviving edges in one pass. Removal is rarer than
/// insertion, so paying for a rebuild here is cheaper than keeping the
/// index correct incrementally.
fn remove_edge_unchecked(&mut self, from: usize, to: usize) {
self.edges
.retain(|edge| !(edge.from == from && edge.to == to));
self.rebuild_index();
}
/// Rebuilds the adjacency index from the current edge list.
fn rebuild_index(&mut self) {
for positions in &mut self.outgoing {
positions.clear();
}
for (position, edge) in self.edges.iter().enumerate() {
self.outgoing[edge.from].push(position);
}
}
/// 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.outgoing
.get(node)
.map(|positions| positions.iter().map(|&p| self.edges[p].to).collect())
.unwrap_or_default()
}
/// Returns the number of edges leaving a node.
pub fn out_degree(&self, node: usize) -> usize {
self.outgoing.get(node).map_or(0, Vec::len)
}
/// 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()
}
}