diff --git a/docs/source/api/layout_functions.rst b/docs/source/api/layout_functions.rst index 7012c0ef72..0f59a1708d 100644 --- a/docs/source/api/layout_functions.rst +++ b/docs/source/api/layout_functions.rst @@ -8,6 +8,7 @@ Layout Functions rustworkx.random_layout rustworkx.spring_layout + rustworkx.kamada_kawai_layout rustworkx.bipartite_layout rustworkx.circular_layout rustworkx.shell_layout diff --git a/releasenotes/notes/add-kamada-kawai-layout-b5a09cd65656e99f.yaml b/releasenotes/notes/add-kamada-kawai-layout-b5a09cd65656e99f.yaml new file mode 100644 index 0000000000..1607a2b66a --- /dev/null +++ b/releasenotes/notes/add-kamada-kawai-layout-b5a09cd65656e99f.yaml @@ -0,0 +1,27 @@ +--- +features: + - | + Added a new layout function, + :func:`~rustworkx.kamada_kawai_layout`, which positions nodes using + the Kamada-Kawai path-length cost function. The function works with + both :class:`~rustworkx.PyGraph` and :class:`~rustworkx.PyDiGraph` + inputs. The implementation follows the original 1989 algorithm of + Kamada and Kawai: an outer loop selects the node with the largest + partial-gradient norm and an inner loop applies a 2D Newton step + against the local Hessian until convergence. + + Disconnected graphs are handled by laying out each connected + component independently and packing the components in a horizontal + row. This avoids the visual collapse seen with single-objective + Kamada-Kawai on disconnected inputs. + + Example usage: + + .. jupyter-execute:: + + import rustworkx + from rustworkx.visualization import mpl_draw + + graph = rustworkx.generators.hexagonal_lattice_graph(2, 2) + layout = rustworkx.kamada_kawai_layout(graph) + mpl_draw(graph, pos=layout) diff --git a/rustworkx/__init__.py b/rustworkx/__init__.py index 107d06f84c..8071ce4dae 100644 --- a/rustworkx/__init__.py +++ b/rustworkx/__init__.py @@ -987,6 +987,113 @@ def networkx_converter(graph, keep_attributes: bool = False): return new_graph +@_rustworkx_dispatch +def kamada_kawai_layout( + graph, + pos=None, + fixed=None, + weight_fn=None, + default_weight=1.0, + epsilon=1e-4, + max_outer=500, + max_inner=10, + scale=1.0, + center=None, +): + """ + Position nodes using the Kamada-Kawai path-length cost-function. + + The layout minimises the energy + + .. math:: + + E = \\frac{1}{2} \\sum_{i Pos2DMapping: ... +def kamada_kawai_layout( + graph: PyGraph[_S, _T] | PyDiGraph[_S, _T], + pos: dict[int, tuple[float, float]] | None = ..., + fixed: set[int] | None = ..., + weight_fn: Callable[[_T], float] | None = ..., + default_weight: float = ..., + epsilon: float = ..., + max_outer: int = ..., + max_inner: int = ..., + scale: float = ..., + center: tuple[float, float] | None = ..., +) -> Pos2DMapping: ... def networkx_converter(graph: Any, keep_attributes: bool = ...) -> PyGraph | PyDiGraph: ... def bipartite_layout( graph: PyGraph[_S, _T] | PyDiGraph[_S, _T], diff --git a/rustworkx/rustworkx.pyi b/rustworkx/rustworkx.pyi index 715c92f77d..7ad45cf77b 100644 --- a/rustworkx/rustworkx.pyi +++ b/rustworkx/rustworkx.pyi @@ -597,6 +597,32 @@ def graph_spring_layout( seed: int | None = ..., /, ) -> Pos2DMapping: ... +def digraph_kamada_kawai_layout( + graph: PyDiGraph[_S, _T], + pos: dict[int, tuple[float, float]] | None = ..., + fixed: set[int] | None = ..., + weight_fn: Callable[[_T], float] | None = ..., + default_weight: float = ..., + epsilon: float = ..., + max_outer: int = ..., + max_inner: int = ..., + scale: float = ..., + center: tuple[float, float] | None = ..., + /, +) -> Pos2DMapping: ... +def graph_kamada_kawai_layout( + graph: PyGraph[_S, _T], + pos: dict[int, tuple[float, float]] | None = ..., + fixed: set[int] | None = ..., + weight_fn: Callable[[_T], float] | None = ..., + default_weight: float = ..., + epsilon: float = ..., + max_outer: int = ..., + max_inner: int = ..., + scale: float = ..., + center: tuple[float, float] | None = ..., + /, +) -> Pos2DMapping: ... # Line graph diff --git a/src/layout/kamada_kawai.rs b/src/layout/kamada_kawai.rs new file mode 100644 index 0000000000..9adfb0eee8 --- /dev/null +++ b/src/layout/kamada_kawai.rs @@ -0,0 +1,387 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +//! Kamada-Kawai force-directed graph layout (Kamada & Kawai 1989). +//! +//! For each connected component, the layout minimises the energy +//! +//! E = (1/2) sum_{i( + py: Python, + graph: &StablePyGraph, + weight_fn: &Option>, + default_weight: Nt, +) -> PyResult> { + let n_bound = graph.node_bound(); + + let mut edge_weights: Vec> = vec![None; graph.edge_bound() + 1]; + for e in graph.edge_references() { + let w = weight_callable(py, weight_fn, e.weight(), default_weight)?; + if w < 0.0 { + return Err(PyValueError::new_err( + "kamada_kawai_layout requires non-negative edge weights", + )); + } + edge_weights[e.id().index()] = Some(w); + } + + let edge_cost = |e: petgraph::stable_graph::EdgeIndex| -> PyResult { + edge_weights[e.index()].ok_or_else(|| PyValueError::new_err("Missing edge weight")) + }; + + let nodes: Vec = graph.node_indices().collect(); + let rows: Vec<(usize, Vec>)> = nodes + .par_iter() + .map(|src| { + let lengths: PyResult>> = + dijkstra(graph, *src, None, |e| edge_cost(e.id()), None); + (src.index(), lengths.unwrap()) + }) + .collect(); + + let mut matrix = vec![Nt::INFINITY; n_bound * n_bound]; + for (i, lengths) in rows { + matrix[i * n_bound + i] = 0.0; + for (j, d_opt) in lengths.into_iter().enumerate() { + if let Some(d) = d_opt { + matrix[i * n_bound + j] = d; + } + } + } + + if Ty::is_directed() { + for i in 0..n_bound { + for j in (i + 1)..n_bound { + let d_ij = matrix[i * n_bound + j]; + let d_ji = matrix[j * n_bound + i]; + let d = d_ij.min(d_ji); + matrix[i * n_bound + j] = d; + matrix[j * n_bound + i] = d; + } + } + } + + Ok(matrix) +} + +fn circular_init_subset(node_indices: &[usize], pos: &mut [Point]) { + let n = node_indices.len(); + if n == 0 { + return; + } + if n == 1 { + pos[node_indices[0]] = [0.0, 0.0]; + return; + } + for (k, &i) in node_indices.iter().enumerate() { + let theta = 2.0 * std::f64::consts::PI * (k as Nt) / (n as Nt); + pos[i] = [theta.cos(), theta.sin()]; + } +} + +#[allow(clippy::too_many_arguments)] +fn kk_solve_component( + dist: &[Nt], + pos: &mut [Point], + active: &[usize], + fixed: &HashSet, + n_bound: usize, + epsilon: Nt, + max_outer: usize, + max_inner: usize, +) { + if active.len() < 2 { + return; + } + + let mut d_max: Nt = 0.0; + for &i in active { + for &j in active { + if i == j { + continue; + } + let d = dist[i * n_bound + j]; + if d.is_finite() && d > d_max { + d_max = d; + } + } + } + if d_max <= 0.0 { + return; + } + + let movable: Vec = active + .iter() + .copied() + .filter(|i| !fixed.contains(i)) + .collect(); + if movable.is_empty() { + return; + } + + let delta_norm = |pos: &[Point], m: usize| -> Nt { + let mut gx = 0.0; + let mut gy = 0.0; + let pm = pos[m]; + for &i in active { + if i == m { + continue; + } + let d = dist[m * n_bound + i]; + if !d.is_finite() || d <= 0.0 { + continue; + } + let l = d / d_max; + let k = 1.0 / (d * d); + let dx = pm[0] - pos[i][0]; + let dy = pm[1] - pos[i][1]; + let r = (dx * dx + dy * dy).sqrt().max(LBOUND); + let coeff = k * (1.0 - l / r); + gx += coeff * dx; + gy += coeff * dy; + } + (gx * gx + gy * gy).sqrt() + }; + + for _ in 0..max_outer { + let mut m = movable[0]; + let mut max_d = delta_norm(pos, m); + for &cand in &movable[1..] { + let d = delta_norm(pos, cand); + if d > max_d { + m = cand; + max_d = d; + } + } + if max_d < epsilon { + break; + } + + for _ in 0..max_inner { + let (mut a, mut b, mut c) = (0.0, 0.0, 0.0); + let (mut gx, mut gy) = (0.0, 0.0); + let pm = pos[m]; + for &i in active { + if i == m { + continue; + } + let d = dist[m * n_bound + i]; + if !d.is_finite() || d <= 0.0 { + continue; + } + let l = d / d_max; + let k = 1.0 / (d * d); + let dx = pm[0] - pos[i][0]; + let dy = pm[1] - pos[i][1]; + let r2 = (dx * dx + dy * dy).max(LBOUND * LBOUND); + let r = r2.sqrt(); + let r3 = r2 * r; + + let coeff = k * (1.0 - l / r); + gx += coeff * dx; + gy += coeff * dy; + + a += k * (1.0 - l * dy * dy / r3); + c += k * (1.0 - l * dx * dx / r3); + b += k * l * dx * dy / r3; + } + + let det = a * c - b * b; + if det.abs() < LBOUND { + let gn = (gx * gx + gy * gy).sqrt().max(LBOUND); + pos[m][0] -= 0.1 * gx / gn; + pos[m][1] -= 0.1 * gy / gn; + break; + } + + let dx_step = (-c * gx + b * gy) / det; + let dy_step = (b * gx - a * gy) / det; + pos[m][0] += dx_step; + pos[m][1] += dy_step; + + if (gx * gx + gy * gy).sqrt() < epsilon { + break; + } + } + } +} + +fn pack_components(pos: &mut [Point], components: &[Vec]) { + if components.len() <= 1 { + return; + } + + let padding = 1.0; + let mut x_offset: Nt = 0.0; + + for component in components { + if component.is_empty() { + continue; + } + + let (mut min_x, mut max_x) = (Nt::INFINITY, Nt::NEG_INFINITY); + let (mut min_y, mut max_y) = (Nt::INFINITY, Nt::NEG_INFINITY); + for &i in component { + let p = pos[i]; + if p[0] < min_x { + min_x = p[0]; + } + if p[0] > max_x { + max_x = p[0]; + } + if p[1] < min_y { + min_y = p[1]; + } + if p[1] > max_y { + max_y = p[1]; + } + } + let cx = (min_x + max_x) / 2.0; + let cy = (min_y + max_y) / 2.0; + let width = max_x - min_x; + let half_w = width / 2.0; + + let target_cx = x_offset + half_w; + let dx = target_cx - cx; + let dy = -cy; + for &i in component { + pos[i][0] += dx; + pos[i][1] += dy; + } + x_offset += width + padding; + } +} + +#[allow(clippy::too_many_arguments)] +pub fn kamada_kawai_layout( + py: Python, + graph: &StablePyGraph, + pos: Option>, + fixed: Option>, + weight_fn: Option>, + default_weight: Nt, + epsilon: Nt, + max_outer: usize, + max_inner: usize, + scale: Option, + center: Option, +) -> PyResult { + if fixed.is_some() && pos.is_none() { + return Err(PyValueError::new_err("`fixed` specified but `pos` not.")); + } + + if graph.node_count() == 0 { + return Ok(Pos2DMapping { + pos_map: rustworkx_core::dictmap::DictMap::default(), + }); + } + + let n_bound = graph.node_bound(); + let active: Vec = graph.node_indices().map(|n| n.index()).collect(); + + let dist = distance_matrix(py, graph, &weight_fn, default_weight)?; + + let raw_components = connected_components(graph); + let components: Vec> = raw_components + .into_iter() + .map(|c| { + let mut v: Vec = c.iter().map(|n| n.index()).collect(); + v.sort_unstable(); + v + }) + .collect(); + + let mut vpos: Vec = vec![[0.0, 0.0]; n_bound]; + for component in &components { + circular_init_subset(component, &mut vpos); + } + let user_provided_pos = pos.is_some(); + if let Some(provided) = pos { + for (i, p) in provided { + if i < n_bound { + vpos[i] = p; + } + } + } + + let fixed = fixed.unwrap_or_default(); + + for component in &components { + kk_solve_component( + &dist, &mut vpos, component, &fixed, n_bound, epsilon, max_outer, max_inner, + ); + } + + let should_pack = components.len() > 1 && fixed.is_empty() && !user_provided_pos; + if should_pack { + pack_components(&mut vpos, &components); + } + + if fixed.is_empty() { + if let Some(s) = scale { + rescale(&mut vpos, s, active.clone()); + } + if let Some(c) = center { + recenter(&mut vpos, c); + } + } + + Ok(Pos2DMapping { + pos_map: graph + .node_indices() + .map(|n| { + let n = n.index(); + (n, vpos[n]) + }) + .collect(), + }) +} diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 8b8963ee6f..2d383b77dc 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -12,6 +12,7 @@ mod bipartite; mod circular; +mod kamada_kawai; mod random; mod shell; mod spiral; @@ -195,6 +196,124 @@ pub fn digraph_spring_layout( ) } +/// Position nodes using the Kamada-Kawai path-length cost-function. +/// +/// The layout minimises the energy +/// :math:`E = \frac{1}{2} \sum_{i>, + fixed: Option>, + weight_fn: Option>, + default_weight: f64, + epsilon: f64, + max_outer: usize, + max_inner: usize, + scale: f64, + center: Option, +) -> PyResult { + kamada_kawai::kamada_kawai_layout( + py, + &graph.graph, + pos, + fixed, + weight_fn, + default_weight, + epsilon, + max_outer, + max_inner, + Some(scale), + center, + ) +} + +/// Position nodes using the Kamada-Kawai path-length cost-function. +/// +/// See :func:`~rustworkx.graph_kamada_kawai_layout` for a full parameter +/// description. Directed edges are treated as undirected for the +/// purposes of computing graph-theoretic distances. +#[pyfunction] +#[pyo3( + signature=(graph, pos=None, fixed=None, weight_fn=None, default_weight=1., epsilon=1e-4, max_outer=500, max_inner=10, scale=1., center=None), + text_signature = "(graph, pos=None, fixed=None, weight_fn=None, default_weight=1.0, + epsilon=1e-4, max_outer=500, max_inner=10, scale=1.0, center=None, /)" +)] +#[allow(clippy::too_many_arguments)] +pub fn digraph_kamada_kawai_layout( + py: Python, + graph: &digraph::PyDiGraph, + pos: Option>, + fixed: Option>, + weight_fn: Option>, + default_weight: f64, + epsilon: f64, + max_outer: usize, + max_inner: usize, + scale: f64, + center: Option, +) -> PyResult { + kamada_kawai::kamada_kawai_layout( + py, + &graph.graph, + pos, + fixed, + weight_fn, + default_weight, + epsilon, + max_outer, + max_inner, + Some(scale), + center, + ) +} + /// Generate a random layout /// /// :param PyGraph graph: The graph to generate the layout for diff --git a/src/lib.rs b/src/lib.rs index a7482d39c3..e40c98f46d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -657,6 +657,8 @@ fn rustworkx(py: Python<'_>, m: &Bound) -> PyResult<()> { m.add_wrapped(wrap_pyfunction!(digraph_bipartite_layout))?; m.add_wrapped(wrap_pyfunction!(graph_circular_layout))?; m.add_wrapped(wrap_pyfunction!(digraph_circular_layout))?; + m.add_wrapped(wrap_pyfunction!(graph_kamada_kawai_layout))?; + m.add_wrapped(wrap_pyfunction!(digraph_kamada_kawai_layout))?; m.add_wrapped(wrap_pyfunction!(graph_shell_layout))?; m.add_wrapped(wrap_pyfunction!(digraph_shell_layout))?; m.add_wrapped(wrap_pyfunction!(graph_spiral_layout))?; diff --git a/tests/digraph/test_layout.py b/tests/digraph/test_layout.py index 5d5b1a0b0f..e6957097fd 100644 --- a/tests/digraph/test_layout.py +++ b/tests/digraph/test_layout.py @@ -474,3 +474,86 @@ def test_spiral_layout_equidistant(self): 9: (-1.0, 0.2018583081028111), } self.assertLayoutEquiv(expected, res) + + +class TestKamadaKawaiLayout(LayoutTest): + """Structural tests for kamada_kawai_layout on PyDiGraph. + + K-K is fundamentally undirected -- the implementation symmetrises + the all-pairs distance matrix before optimising -- so the same + structural properties as the PyGraph case should hold. + """ + + @staticmethod + def _dist(pos, i, j): + import math + return math.hypot(pos[i][0] - pos[j][0], pos[i][1] - pos[j][1]) + + @staticmethod + def _centroid(pos): + xs = [p[0] for p in pos.values()] + ys = [p[1] for p in pos.values()] + n = len(pos) + return (sum(xs) / n, sum(ys) / n) + + def test_empty_graph(self): + graph = rustworkx.PyDiGraph() + result = rustworkx.kamada_kawai_layout(graph) + self.assertEqual(len(result), 0) + + def test_single_node(self): + graph = rustworkx.PyDiGraph() + graph.add_node(0) + result = rustworkx.kamada_kawai_layout(graph) + self.assertEqual(len(result), 1) + + def test_directed_4_cycle_is_square(self): + # Without symmetrisation a directed 4-cycle would have + # asymmetric distances (1, 2, 3 around the cycle). Our + # implementation symmetrises, so the layout should still be a + # square: equal sides and diag/side = sqrt(2). + import math + graph = rustworkx.generators.directed_cycle_graph(4) + result = rustworkx.kamada_kawai_layout(graph) + sides = [self._dist(result, i, (i + 1) % 4) for i in range(4)] + diagonals = [self._dist(result, 0, 2), self._dist(result, 1, 3)] + for s in sides: + self.assertAlmostEqual(s, sides[0], places=2) + self.assertAlmostEqual(diagonals[0] / sides[0], math.sqrt(2), places=2) + + def test_fixed_nodes_do_not_move(self): + graph = rustworkx.generators.directed_cycle_graph(5) + initial = {0: (0.0, 0.0), 1: (1.0, 0.0)} + result = rustworkx.kamada_kawai_layout( + graph, pos=initial, fixed={0, 1} + ) + self.assertAlmostEqual(result[0][0], 0.0, places=10) + self.assertAlmostEqual(result[1][0], 1.0, places=10) + + def test_fixed_without_pos_raises(self): + graph = rustworkx.generators.directed_cycle_graph(4) + with self.assertRaises(ValueError): + rustworkx.kamada_kawai_layout(graph, fixed={0}) + + def test_negative_weight_raises(self): + graph = rustworkx.PyDiGraph() + graph.add_nodes_from([0, 1]) + graph.add_edge(0, 1, -1.0) + with self.assertRaises(ValueError): + rustworkx.kamada_kawai_layout( + graph, weight_fn=lambda w: float(w) + ) + + def test_disconnected_graph_returns_layout_for_all_nodes(self): + import math + graph = rustworkx.PyDiGraph() + graph.add_nodes_from(list(range(6))) + graph.add_edges_from([ + (0, 1, 1.0), (1, 2, 1.0), (2, 0, 1.0), + (3, 4, 1.0), (4, 5, 1.0), (5, 3, 1.0), + ]) + result = rustworkx.kamada_kawai_layout(graph) + self.assertEqual(len(result), 6) + for p in result.values(): + self.assertTrue(math.isfinite(p[0])) + self.assertTrue(math.isfinite(p[1])) diff --git a/tests/graph/test_layout.py b/tests/graph/test_layout.py index c656b7fe5c..1a6afa0115 100644 --- a/tests/graph/test_layout.py +++ b/tests/graph/test_layout.py @@ -474,3 +474,204 @@ def test_spiral_layout_equidistant(self): 9: (-1.0, 0.2018583081028111), } self.assertLayoutEquiv(expected, res) + + +class TestKamadaKawaiLayout(LayoutTest): + """Structural tests for kamada_kawai_layout on PyGraph. + + K-K is iterative and the exact coordinates are sensitive to + inner-loop refactors, so these tests check structural properties + of the output (relative distances, symmetries, fixed-point + behaviour) rather than pinned coordinates. + """ + + @staticmethod + def _dist(pos, i, j): + import math + return math.hypot(pos[i][0] - pos[j][0], pos[i][1] - pos[j][1]) + + @staticmethod + def _max_coord(pos): + return max(max(abs(p[0]), abs(p[1])) for p in pos.values()) + + @staticmethod + def _centroid(pos): + xs = [p[0] for p in pos.values()] + ys = [p[1] for p in pos.values()] + n = len(pos) + return (sum(xs) / n, sum(ys) / n) + + def make_graph(self, generator): + return generator + + # ---- edge cases ---------------------------------------------------- + + def test_empty_graph(self): + graph = rustworkx.PyGraph() + result = rustworkx.kamada_kawai_layout(graph) + self.assertEqual(len(result), 0) + + def test_single_node(self): + graph = rustworkx.PyGraph() + graph.add_node(0) + result = rustworkx.kamada_kawai_layout(graph) + self.assertEqual(len(result), 1) + self.assertEqual(len(result[0]), 2) + + def test_two_node_path_endpoints(self): + graph = rustworkx.generators.path_graph(2) + result = rustworkx.kamada_kawai_layout(graph, scale=1.0) + # After rescale the two endpoints should be at opposite extremes. + d = self._dist(result, 0, 1) + self.assertAlmostEqual(d, 2.0, places=4) + + # ---- structural properties on regular graphs ----------------------- + + def test_4_cycle_is_square(self): + # Four-cycle should land on a square: equal sides, equal + # diagonals, diagonal/side = sqrt(2). + import math + graph = rustworkx.generators.cycle_graph(4) + result = rustworkx.kamada_kawai_layout(graph) + sides = [self._dist(result, i, (i + 1) % 4) for i in range(4)] + diagonals = [self._dist(result, 0, 2), self._dist(result, 1, 3)] + for s in sides: + self.assertAlmostEqual(s, sides[0], places=2) + for d in diagonals: + self.assertAlmostEqual(d, diagonals[0], places=2) + self.assertAlmostEqual(diagonals[0] / sides[0], math.sqrt(2), places=2) + + def test_complete_5_is_regular_pentagon(self): + # K_n with equal edge weights cannot have all pairwise + # distances equal in 2D for n >= 4. The K-K optimum for K_5 + # is a regular pentagon: equidistant from centroid, and the + # side/diagonal ratio matches phi = (1 + sqrt(5)) / 2. + import math + graph = rustworkx.generators.complete_graph(5) + result = rustworkx.kamada_kawai_layout(graph) + cx, cy = self._centroid(result) + radii = [ + math.hypot(result[i][0] - cx, result[i][1] - cy) for i in range(5) + ] + for r in radii: + self.assertAlmostEqual(r / radii[0], 1.0, places=2) + + distances = sorted( + self._dist(result, i, j) for i in range(5) for j in range(i + 1, 5) + ) + avg_side = sum(distances[:5]) / 5 + avg_diag = sum(distances[5:]) / 5 + phi = (1 + math.sqrt(5)) / 2 + self.assertAlmostEqual(avg_diag / avg_side, phi, places=1) + + def test_path_is_collinear(self): + # The optimal K-K layout for a path is a straight line. Verify + # via PCA: the smaller eigenvalue of the centered position + # covariance should be much smaller than the larger. + import math + graph = rustworkx.generators.path_graph(6) + result = rustworkx.kamada_kawai_layout(graph) + cx, cy = self._centroid(result) + sxx = sum((p[0] - cx) ** 2 for p in result.values()) + syy = sum((p[1] - cy) ** 2 for p in result.values()) + sxy = sum((p[0] - cx) * (p[1] - cy) for p in result.values()) + tr = sxx + syy + det = sxx * syy - sxy * sxy + disc = max(tr * tr - 4 * det, 0.0) + lam1 = (tr + math.sqrt(disc)) / 2 + lam2 = (tr - math.sqrt(disc)) / 2 + self.assertGreater(lam1, 0) + self.assertLess(lam2 / lam1, 0.05) + + # ---- parameter handling ------------------------------------------- + + def test_scale(self): + graph = rustworkx.generators.cycle_graph(5) + small = rustworkx.kamada_kawai_layout(graph, scale=1.0) + large = rustworkx.kamada_kawai_layout(graph, scale=3.0) + self.assertAlmostEqual(self._max_coord(small), 1.0, places=4) + self.assertAlmostEqual(self._max_coord(large), 3.0, places=4) + + def test_center(self): + graph = rustworkx.generators.cycle_graph(5) + result = rustworkx.kamada_kawai_layout(graph, center=(5.0, -3.0)) + cx, cy = self._centroid(result) + self.assertAlmostEqual(cx, 5.0, places=4) + self.assertAlmostEqual(cy, -3.0, places=4) + + def test_fixed_nodes_do_not_move(self): + graph = rustworkx.generators.cycle_graph(5) + initial = {0: (0.0, 0.0), 1: (1.0, 0.0)} + result = rustworkx.kamada_kawai_layout( + graph, pos=initial, fixed={0, 1} + ) + self.assertAlmostEqual(result[0][0], 0.0, places=10) + self.assertAlmostEqual(result[0][1], 0.0, places=10) + self.assertAlmostEqual(result[1][0], 1.0, places=10) + self.assertAlmostEqual(result[1][1], 0.0, places=10) + + def test_fixed_without_pos_raises(self): + graph = rustworkx.generators.cycle_graph(4) + with self.assertRaises(ValueError): + rustworkx.kamada_kawai_layout(graph, fixed={0}) + + def test_weight_fn_is_used(self): + graph = rustworkx.PyGraph() + for _ in range(4): + graph.add_node(None) + graph.add_edges_from([(0, 1, 1.0), (1, 2, 5.0), (2, 3, 1.0)]) + + uniform = rustworkx.kamada_kawai_layout( + graph, weight_fn=lambda _w: 1.0 + ) + weighted = rustworkx.kamada_kawai_layout( + graph, weight_fn=lambda w: float(w) + ) + # The 5.0 edge should pull nodes 1 and 2 further apart. + self.assertGreater( + self._dist(weighted, 1, 2), self._dist(uniform, 1, 2) + ) + + def test_negative_weight_raises(self): + graph = rustworkx.PyGraph() + graph.add_nodes_from([0, 1]) + graph.add_edge(0, 1, -1.0) + with self.assertRaises(ValueError): + rustworkx.kamada_kawai_layout( + graph, weight_fn=lambda w: float(w) + ) + + # ---- disconnected graphs ------------------------------------------ + + def test_disconnected_graph_returns_layout_for_all_nodes(self): + import math + graph = rustworkx.PyGraph() + graph.add_nodes_from(list(range(6))) + graph.add_edges_from([ + (0, 1, 1.0), (1, 2, 1.0), (2, 0, 1.0), + (3, 4, 1.0), (4, 5, 1.0), (5, 3, 1.0), + ]) + result = rustworkx.kamada_kawai_layout(graph) + self.assertEqual(len(result), 6) + for p in result.values(): + self.assertTrue(math.isfinite(p[0])) + self.assertTrue(math.isfinite(p[1])) + + # Components should be packed side-by-side without overlap: + # the minimum cross-component distance should exceed the + # within-component edge length. + intra = self._dist(result, 0, 1) + cross = min( + self._dist(result, i, j) for i in (0, 1, 2) for j in (3, 4, 5) + ) + self.assertGreater(cross, intra * 0.5) + + # ---- determinism -------------------------------------------------- + + def test_deterministic(self): + graph = rustworkx.generators.complete_graph(6) + a = rustworkx.kamada_kawai_layout(graph) + b = rustworkx.kamada_kawai_layout(graph) + for n in a: + self.assertAlmostEqual(a[n][0], b[n][0], places=10) + self.assertAlmostEqual(a[n][1], b[n][1], places=10)