layout.rs
8.2 kB · rust · 247 lines
1use super::models::Network;2use mrlycore::errors::{value_error, Result};3use mrlycore::Rng;45const SPAN: usize = 300;6const FLOOR: f64 = 0.02;7const GAIN: f64 = 0.1;8const JITTER: f64 = 0.05;9const APART: f64 = 1e-9;1011/// A force-directed layout: every node repels every other, every branch pulls its ends together, and a cooling cap on the move per tick lets the lattice settle.12///13/// The forces are Fruchterman and Reingold's: repulsion `k^2 / d` between every pair, attraction14/// `d^2 / k` along every branch, with `k` the ideal length `extent / n^(1/dim)` read off the15/// starting box. The temperature caps the move of any node in one tick and cools linearly from16/// a tenth of the extent to a floor over the first ticks, then holds, so the layout keeps17/// creeping toward rest. The seed jitters the start so a symmetric lattice can fold.18///19/// ```20/// let square = [0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0];21/// let ring = [(0, 1), (1, 2), (2, 3), (3, 0)];22/// let mut layout = mrlynum::graph::Layout::new(&square, &ring, 2, 1).unwrap();23/// assert!(layout.step(500) < 1e-3);24/// ```25#[derive(Clone)]26pub struct Layout {27 dim: usize,28 positions: Vec<f64>,29 branches: Vec<(usize, usize)>,30 ideal: f64,31 heat: f64,32 tick: usize,33 energy: f64,34 moved: f64,35 rng: Rng,36}3738impl Layout {39 /// Starts a layout from flat positions, `dim` floats per node, and the branch pairs, or an error when a branch points off the list.40 pub fn new(41 positions: &[f64],42 branches: &[(usize, usize)],43 dim: usize,44 seed: u64,45 ) -> Result<Layout> {46 if dim == 0 || !positions.len().is_multiple_of(dim) {47 return value_error("positions must hold dim floats per node.");48 }49 let count = positions.len() / dim;50 if branches.iter().any(|&(a, b)| a >= count || b >= count) {51 return value_error("a branch points past the last node.");52 }53 let mut extent: f64 = 0.0;54 for axis in 0..dim {55 let column = positions.iter().skip(axis).step_by(dim).copied();56 let low = column.clone().fold(f64::INFINITY, f64::min);57 let high = column.fold(f64::NEG_INFINITY, f64::max);58 extent = extent.max(high - low);59 }60 if extent <= 0.0 || extent.is_nan() {61 extent = 1.0;62 }63 let ideal = extent / (count.max(1) as f64).powf(1.0 / dim as f64);64 let mut rng = Rng::new(seed);65 let positions = positions66 .iter()67 .map(|&p| p + (rng.unit() - 0.5) * 2.0 * JITTER * ideal)68 .collect();69 Ok(Layout {70 dim,71 positions,72 branches: branches.to_vec(),73 ideal,74 heat: extent / 10.0,75 tick: 0,76 energy: 0.0,77 moved: 0.0,78 rng,79 })80 }81 /// Starts a layout from a network's own positions and branches.82 pub fn from_network(network: &Network, seed: u64) -> Result<Layout> {83 let positions: Vec<f64> = network84 .nodes85 .iter()86 .flat_map(|node| node.position.iter().copied())87 .collect();88 let branches: Vec<(usize, usize)> = network89 .branches90 .iter()91 .map(|b| (b.parent, b.child))92 .collect();93 Layout::new(&positions, &branches, network.dim, seed)94 }95 /// Returns the node count.96 pub fn nodes(&self) -> usize {97 self.positions.len() / self.dim98 }99 /// Returns the ideal branch length `k`.100 pub fn ideal(&self) -> f64 {101 self.ideal102 }103 /// Returns the cap on one node's move in the next tick.104 pub fn temperature(&self) -> f64 {105 let cooled = 1.0 - self.tick.min(SPAN) as f64 / SPAN as f64;106 self.heat * cooled.max(FLOOR)107 }108 /// Returns the ticks stepped so far.109 pub fn ticks(&self) -> usize {110 self.tick111 }112 /// Runs the ticks and returns the energy left: the mean net force per node in units of `k`.113 pub fn step(&mut self, ticks: usize) -> f64 {114 for _ in 0..ticks {115 self.tick_once();116 }117 self.energy118 }119 fn tick_once(&mut self) {120 let (dim, n, k) = (self.dim, self.nodes(), self.ideal);121 let mut push = vec![0.0; n * dim];122 let mut delta = vec![0.0; dim];123 for i in 0..n {124 for j in (i + 1)..n {125 let d = gap(&self.positions, dim, i, j, &mut delta, &mut self.rng);126 let f = k * k / (d * d);127 for a in 0..dim {128 push[i * dim + a] += delta[a] * f;129 push[j * dim + a] -= delta[a] * f;130 }131 }132 }133 for &(i, j) in &self.branches {134 let d = gap(&self.positions, dim, i, j, &mut delta, &mut self.rng);135 let f = d / k;136 for a in 0..dim {137 push[i * dim + a] -= delta[a] * f;138 push[j * dim + a] += delta[a] * f;139 }140 }141 let cap = self.temperature();142 let (mut force, mut moved) = (0.0, 0.0);143 for i in 0..n {144 let len = (0..dim)145 .map(|a| push[i * dim + a].powi(2))146 .sum::<f64>()147 .sqrt();148 force += len;149 if len == 0.0 {150 continue;151 }152 let reach = (len * GAIN).min(cap);153 moved += reach;154 for a in 0..dim {155 self.positions[i * dim + a] += push[i * dim + a] / len * reach;156 }157 }158 self.energy = force / (n.max(1) as f64 * k);159 self.moved = moved / n.max(1) as f64;160 self.tick += 1;161 }162 /// Returns the positions, `dim` floats per node.163 pub fn positions(&self) -> &[f64] {164 &self.positions165 }166 /// Returns the mean net force per node in units of `k` after the last tick.167 pub fn energy(&self) -> f64 {168 self.energy169 }170 /// Returns the mean distance a node moved in the last tick.171 pub fn moved(&self) -> f64 {172 self.moved173 }174}175176fn gap(positions: &[f64], dim: usize, i: usize, j: usize, delta: &mut [f64], rng: &mut Rng) -> f64 {177 for a in 0..dim {178 delta[a] = positions[i * dim + a] - positions[j * dim + a];179 }180 let d = delta.iter().map(|x| x * x).sum::<f64>().sqrt();181 if d >= APART {182 return d;183 }184 for x in delta.iter_mut() {185 *x = (rng.unit() - 0.5) * APART;186 }187 delta188 .iter()189 .map(|x| x * x)190 .sum::<f64>()191 .sqrt()192 .max(APART / 2.0)193}194195#[cfg(test)]196mod tests {197 use super::*;198 fn ring() -> Layout {199 let square = [0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0];200 let ring = [(0, 1), (1, 2), (2, 3), (3, 0)];201 Layout::new(&square, &ring, 2, 1).unwrap()202 }203 #[test]204 fn the_four_cycle_relaxes_to_a_square() {205 let mut layout = ring();206 assert!((layout.ideal() - 0.5).abs() < 1e-12);207 let rest = layout.step(500);208 assert!(rest < 1e-3, "energy {rest}");209 let p = layout.positions();210 let gaps: Vec<f64> = [(0, 1), (1, 2), (2, 3), (3, 0)]211 .iter()212 .map(|&(a, b)| {213 ((p[2 * a] - p[2 * b]).powi(2) + (p[2 * a + 1] - p[2 * b + 1]).powi(2)).sqrt()214 })215 .collect();216 let spread = gaps.iter().cloned().fold(0.0, f64::max)217 - gaps.iter().cloned().fold(f64::INFINITY, f64::min);218 assert!(spread < 1e-3, "gaps {gaps:?}");219 let side = 0.5 * 1.5f64.cbrt();220 assert!((gaps[0] - side).abs() < 1e-2, "side {}", gaps[0]);221 assert_eq!(layout.ticks(), 500);222 }223 #[test]224 fn the_seed_replays_and_the_cap_cools() {225 let mut a = ring();226 let mut b = ring();227 a.step(20);228 b.step(20);229 assert_eq!(a.positions(), b.positions());230 assert!(231 a.temperature()232 < Layout::new(&[0.0, 0.0, 1.0, 1.0], &[], 2, 1)233 .unwrap()234 .temperature()235 );236 assert!(a.moved() > 0.0);237 }238 #[test]239 fn the_faults_are_named() {240 assert!(Layout::new(&[0.0, 0.0, 1.0], &[], 2, 1).is_err());241 assert!(Layout::new(&[0.0, 0.0], &[(0, 1)], 2, 1).is_err());242 assert!(Layout::new(&[0.0, 0.0, 0.0, 0.0], &[(0, 1)], 2, 1)243 .unwrap()244 .step(3)245 .is_finite());246 }247}