From ab1f98425e7a06ab111c8fe3b404b1bf53b4b2d4 Mon Sep 17 00:00:00 2001 From: "Jielun (Chris) Chen" <31495624+Chriscrosser3310@users.noreply.github.com> Date: Mon, 31 May 2021 10:11:14 -0700 Subject: [PATCH] Add four simple layouts (#310) The four simplest layouts were added: bipartite, circular, shell, and spiral. The layout functions return the new type Pos2DMapping Related to #280 * bipartite + circular * add shell and spiral layouts * tests * release note * release note newline * add api * resolve conflicts * clippy * flake8 * fix bipartite empty issue * fix api * fix bipartite doc issue * Update retworkx/__init__.py Co-authored-by: georgios-ts <45130028+georgios-ts@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: georgios-ts <45130028+georgios-ts@users.noreply.github.com> * handle index holes + minor fixes * rename layouts to layout * add hole tests * lints * resolve conflicts * black reformatted * add f64 type declaration * fix bipartite invalid dy * fix shell index holes Co-authored-by: georgios-ts <45130028+georgios-ts@users.noreply.github.com> * lint * add hole test cases that specify shells * lim range check * Update layout tests to use custom assertion method This commit adds a new custom assertion method for comparing layouts with a tolerance (fixed at 1e-6) ensuring that no layout differs from the expected in any coordinate by more than this. If there is a failure it will print a detailed message about which node differs from the expected. With this change locally 2 bipartite layout tests failed so this commit updates the expected values so it passes (the layouts still look like valid bipartite layouts just the center point was different). * Fix lint Co-authored-by: georgios-ts <45130028+georgios-ts@users.noreply.github.com> Co-authored-by: Matthew Treinish --- docs/source/api.rst | 16 + .../simple-layouts-93db8f7f5fa1fe83.yaml | 6 + retworkx/__init__.py | 177 +++++++ src/layout.rs | 229 +++++++++- src/lib.rs | 219 +++++++++ tests/digraph/test_layout.py | 432 +++++++++++++++++- tests/graph/test_layout.py | 430 ++++++++++++++++- 7 files changed, 1504 insertions(+), 5 deletions(-) create mode 100644 releasenotes/notes/simple-layouts-93db8f7f5fa1fe83.yaml diff --git a/docs/source/api.rst b/docs/source/api.rst index 9f86309315..b054289eb2 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -143,6 +143,10 @@ type functions in the algorithms API but can be run with a retworkx.transitivity retworkx.core_number retworkx.random_layout + retworkx.bipartite_layout + retworkx.circular_layout + retworkx.shell_layout + retworkx.spiral_layout retworkx.spring_layout .. _layout-functions: @@ -157,6 +161,18 @@ Layout Functions retworkx.spring_layout retworkx.graph_random_layout retworkx.digraph_random_layout + retworkx.bipartite_layout + retworkx.graph_bipartite_layout + retworkx.digraph_bipartite_layout + retworkx.circular_layout + retworkx.graph_circular_layout + retworkx.digraph_circular_layout + retworkx.shell_layout + retworkx.graph_shell_layout + retworkx.digraph_shell_layout + retworkx.spiral_layout + retworkx.graph_spiral_layout + retworkx.digraph_spiral_layout Converters ---------- diff --git a/releasenotes/notes/simple-layouts-93db8f7f5fa1fe83.yaml b/releasenotes/notes/simple-layouts-93db8f7f5fa1fe83.yaml new file mode 100644 index 0000000000..f27c174dcd --- /dev/null +++ b/releasenotes/notes/simple-layouts-93db8f7f5fa1fe83.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Four simple layouts were added: :func:`~retworkx.bipartite_layout`, + :func:`~retworkx.circular_layout`, :func:`~retworkx.shell_layout`, + and :func:`~retworkx.spiral_layout`. diff --git a/retworkx/__init__.py b/retworkx/__init__.py index 16e50f798b..f48c152835 100644 --- a/retworkx/__init__.py +++ b/retworkx/__init__.py @@ -1046,3 +1046,180 @@ def networkx_converter(graph): ] ) return new_graph + + +@functools.singledispatch +def bipartite_layout( + graph, + first_nodes, + horizontal=False, + scale=1, + center=None, + aspect_ratio=4 / 3, +): + """Generate a bipartite layout of the graph + + :param graph: The graph to generate the layout for. Can either be a + :class:`~retworkx.PyGraph` or :class:`~retworkx.PyDiGraph` + :param set first_nodes: The set of node indexes on the left (or top if + horitontal is true) + :param bool horizontal: An optional bool specifying the orientation of the + layout + :param float scale: An optional scaling factor to scale positions + :param tuple center: An optional center position. This is a 2 tuple of two + ``float`` values for the center position + :param float aspect_ratio: An optional number for the ratio of the width to + the height of the layout. + + :returns: The bipartite layout of the graph. + :rtype: Pos2DMapping + """ + raise TypeError("Invalid Input Type %s for graph" % type(graph)) + + +@bipartite_layout.register(PyDiGraph) +def _digraph_bipartite_layout( + graph, + first_nodes, + horizontal=False, + scale=1, + center=None, + aspect_ratio=4 / 3, +): + return digraph_bipartite_layout( + graph, + first_nodes, + horizontal=horizontal, + scale=scale, + center=center, + aspect_ratio=aspect_ratio, + ) + + +@bipartite_layout.register(PyGraph) +def _graph_bipartite_layout( + graph, + first_nodes, + horizontal=False, + scale=1, + center=None, + aspect_ratio=4 / 3, +): + return graph_bipartite_layout( + graph, + first_nodes, + horizontal=horizontal, + scale=scale, + center=center, + aspect_ratio=aspect_ratio, + ) + + +@functools.singledispatch +def circular_layout(graph, scale=1, center=None): + """Generate a circular layout of the graph + + :param graph: The graph to generate the layout for. Can either be a + :class:`~retworkx.PyGraph` or :class:`~retworkx.PyDiGraph` + :param float scale: An optional scaling factor to scale positions + :param tuple center: An optional center position. This is a 2 tuple of two + ``float`` values for the center position + + :returns: The circular layout of the graph. + :rtype: Pos2DMapping + """ + raise TypeError("Invalid Input Type %s for graph" % type(graph)) + + +@circular_layout.register(PyDiGraph) +def _digraph_circular_layout(graph, scale=1, center=None): + return digraph_circular_layout(graph, scale=scale, center=center) + + +@circular_layout.register(PyGraph) +def _graph_circular_layout(graph, scale=1, center=None): + return graph_circular_layout(graph, scale=scale, center=center) + + +@functools.singledispatch +def shell_layout(graph, nlist=None, rotate=None, scale=1, center=None): + """ + Generate a shell layout of the graph + + :param graph: The graph to generate the layout for. Can either be a + :class:`~retworkx.PyGraph` or :class:`~retworkx.PyDiGraph` + :param list nlist: The list of lists of indexes which represents each shell + :param float rotate: Angle (in radians) by which to rotate the starting + position of each shell relative to the starting position of the + previous shell + :param float scale: An optional scaling factor to scale positions + :param tuple center: An optional center position. This is a 2 tuple of two + ``float`` values for the center position + + :returns: The shell layout of the graph. + :rtype: Pos2DMapping + """ + raise TypeError("Invalid Input Type %s for graph" % type(graph)) + + +@shell_layout.register(PyDiGraph) +def _digraph_shell_layout(graph, nlist=None, rotate=None, scale=1, center=None): + return digraph_shell_layout( + graph, nlist=nlist, rotate=rotate, scale=scale, center=center + ) + + +@shell_layout.register(PyGraph) +def _graph_shell_layout(graph, nlist=None, rotate=None, scale=1, center=None): + return graph_shell_layout( + graph, nlist=nlist, rotate=rotate, scale=scale, center=center + ) + + +@functools.singledispatch +def spiral_layout( + graph, scale=1, center=None, resolution=0.35, equidistant=False +): + """ + Generate a spiral layout of the graph + + :param graph: The graph to generate the layout for. Can either be a + :class:`~retworkx.PyGraph` or :class:`~retworkx.PyDiGraph` + :param float scale: An optional scaling factor to scale positions + :param tuple center: An optional center position. This is a 2 tuple of two + ``float`` values for the center position + :param float resolution: The compactness of the spiral layout returned. + Lower values result in more compressed spiral layouts. + :param bool equidistant: If true, nodes will be plotted equidistant from + each other. + + :returns: The spiral layout of the graph. + :rtype: Pos2DMapping + """ + raise TypeError("Invalid Input Type %s for graph" % type(graph)) + + +@spiral_layout.register(PyDiGraph) +def _digraph_spiral_layout( + graph, scale=1, center=None, resolution=0.35, equidistant=False +): + return digraph_spiral_layout( + graph, + scale=scale, + center=center, + resolution=resolution, + equidistant=equidistant, + ) + + +@spiral_layout.register(PyGraph) +def _graph_spiral_layout( + graph, scale=1, center=None, resolution=0.35, equidistant=False +): + return graph_spiral_layout( + graph, + scale=scale, + center=center, + resolution=resolution, + equidistant=equidistant, + ) diff --git a/src/layout.rs b/src/layout.rs index b553b3759a..4e0a26ff3a 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -18,8 +18,11 @@ use pyo3::prelude::*; use petgraph::graph::NodeIndex; use petgraph::prelude::*; +use petgraph::visit::NodeIndexable; use petgraph::EdgeType; +use crate::iterators::Pos2DMapping; + type Nt = f64; pub type Point = [Nt; 2]; type Graph = StableGraph; @@ -187,9 +190,11 @@ fn rescale(pos: &mut Vec, scale: Nt, indices: Vec) { } // rescale - for [px, py] in pos.iter_mut() { - *px *= scale / lim; - *py *= scale / lim; + if lim > 0.0 { + for [px, py] in pos.iter_mut() { + *px *= scale / lim; + *py *= scale / lim; + } } } @@ -284,3 +289,221 @@ where pos } + +pub fn bipartite_layout( + graph: &StableGraph, + first_nodes: HashSet, + horizontal: Option, + scale: Option, + center: Option, + aspect_ratio: Option, +) -> Pos2DMapping { + let node_num = graph.node_count(); + if node_num == 0 { + return Pos2DMapping { + pos_map: HashMap::new(), + }; + } + let left_num = first_nodes.len(); + let right_num = node_num - left_num; + let mut pos: Vec = Vec::with_capacity(node_num); + + let (width, height); + if horizontal == Some(true) { + // width and height viewed from 90 degrees clockwise rotation + width = 1.0; + height = match aspect_ratio { + Some(aspect_ratio) => aspect_ratio * width, + None => 4.0 * width / 3.0, + }; + } else { + height = 1.0; + width = match aspect_ratio { + Some(aspect_ratio) => aspect_ratio * height, + None => 4.0 * height / 3.0, + }; + } + + let x_offset: f64 = width / 2.0; + let y_offset: f64 = height / 2.0; + let left_dy: f64 = match left_num { + 0 | 1 => 0.0, + _ => height / (left_num - 1) as f64, + }; + let right_dy: f64 = match right_num { + 0 | 1 => 0.0, + _ => height / (right_num - 1) as f64, + }; + + let mut lc: f64 = 0.0; + let mut rc: f64 = 0.0; + + for node in graph.node_indices() { + let n = node.index(); + + let (x, y): (f64, f64); + if first_nodes.contains(&n) { + x = -x_offset; + y = lc * left_dy - y_offset; + lc += 1.0; + } else { + x = width - x_offset; + y = rc * right_dy - y_offset; + rc += 1.0; + } + + if horizontal == Some(true) { + pos.push([-y, x]); + } else { + pos.push([x, y]); + } + } + + if let Some(scale) = scale { + rescale(&mut pos, scale, (0..node_num).collect()); + } + + if let Some(center) = center { + recenter(&mut pos, center); + } + + Pos2DMapping { + pos_map: graph.node_indices().map(|n| n.index()).zip(pos).collect(), + } +} + +pub fn circular_layout( + graph: &StableGraph, + scale: Option, + center: Option, +) -> Pos2DMapping { + let node_num = graph.node_count(); + let mut pos: Vec = Vec::with_capacity(node_num); + let pi = std::f64::consts::PI; + + if node_num == 1 { + pos.push([0.0, 0.0]) + } else { + for i in 0..node_num { + let angle = 2.0 * pi * i as f64 / node_num as f64; + pos.push([angle.cos(), angle.sin()]); + } + } + + if let Some(scale) = scale { + rescale(&mut pos, scale, (0..node_num).collect()); + } + + if let Some(center) = center { + recenter(&mut pos, center); + } + + Pos2DMapping { + pos_map: graph.node_indices().map(|n| n.index()).zip(pos).collect(), + } +} + +pub fn shell_layout( + graph: &StableGraph, + nlist: Option>>, + rotate: Option, + scale: Option, + center: Option, +) -> Pos2DMapping { + let node_num = graph.node_bound(); + let mut pos: Vec = vec![[0.0, 0.0]; node_num]; + let pi = std::f64::consts::PI; + + let shell_list: Vec> = match nlist { + Some(nlist) => nlist, + None => vec![graph.node_indices().map(|n| n.index()).collect()], + }; + let shell_num = shell_list.len(); + + let radius_bump = match scale { + Some(scale) => scale / shell_num as f64, + None => 1.0 / shell_num as f64, + }; + + let mut radius = match node_num { + 1 => 0.0, + _ => radius_bump, + }; + + let rot_angle = match rotate { + Some(rotate) => rotate, + None => pi / shell_num as f64, + }; + + let mut first_theta = rot_angle; + for shell in shell_list { + let shell_len = shell.len(); + for i in 0..shell_len { + let angle = 2.0 * pi * i as f64 / shell_len as f64 + first_theta; + pos[shell[i]] = [radius * angle.cos(), radius * angle.sin()]; + } + radius += radius_bump; + first_theta += rot_angle; + } + + if let Some(center) = center { + recenter(&mut pos, center); + } + + Pos2DMapping { + pos_map: graph + .node_indices() + .map(|n| { + let n = n.index(); + (n, pos[n]) + }) + .collect(), + } +} + +pub fn spiral_layout( + graph: &StableGraph, + scale: Option, + center: Option, + resolution: Option, + equidistant: Option, +) -> Pos2DMapping { + let node_num = graph.node_count(); + let mut pos: Vec = Vec::with_capacity(node_num); + + let ros = resolution.unwrap_or(0.35); + + if node_num == 1 { + pos.push([0.0, 0.0]); + } else if equidistant == Some(true) { + let mut theta: f64 = ros; + let chord = 1.0; + let step = 0.5; + for _ in 0..node_num { + let r = step * theta; + theta += chord / r; + pos.push([theta.cos() * r, theta.sin() * r]); + } + } else { + let mut angle: f64 = 0.0; + let mut dist = 0.0; + let step = 1.0; + for _ in 0..node_num { + pos.push([dist * angle.cos(), dist * angle.sin()]); + dist += step; + angle += ros; + } + } + + if let Some(scale) = scale { + rescale(&mut pos, scale, (0..node_num).collect()); + } + + if let Some(center) = center { + recenter(&mut pos, center); + } + + Pos2DMapping { + pos_map: graph.node_indices().map(|n| n.index()).zip(pos).collect(), + } +} diff --git a/src/lib.rs b/src/lib.rs index 4b455e8369..2c6a998b14 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4231,6 +4231,217 @@ pub fn digraph_random_layout( _random_layout(&graph.graph, center, seed) } +/// Generate a bipartite layout of the graph +/// +/// :param PyGraph graph: The graph to generate the layout for +/// :param set first_nodes: The set of node indexes on the left (or top if +/// horitontal is true) +/// :param bool horizontal: An optional bool specifying the orientation of the +/// layout +/// :param float scale: An optional scaling factor to scale positions +/// :param tuple center: An optional center position. This is a 2 tuple of two +/// ``float`` values for the center position +/// :param float aspect_ratio: An optional number for the ratio of the width to +/// the height of the layout. +/// +/// :returns: The bipartite layout of the graph. +/// :rtype: Pos2DMapping +#[pyfunction] +#[text_signature = "(graph, first_nodes, /, horitontal=False, scale=1, + center=None, aspect_ratio=1.33333333333333)"] +pub fn graph_bipartite_layout( + graph: &graph::PyGraph, + first_nodes: HashSet, + horizontal: Option, + scale: Option, + center: Option, + aspect_ratio: Option, +) -> Pos2DMapping { + layout::bipartite_layout( + &graph.graph, + first_nodes, + horizontal, + scale, + center, + aspect_ratio, + ) +} + +/// Generate a bipartite layout of the graph +/// +/// :param PyDiGraph graph: The graph to generate the layout for +/// :param set first_nodes: The set of node indexes on the left (or top if +/// horizontal is true) +/// :param bool horizontal: An optional bool specifying the orientation of the +/// layout +/// :param float scale: An optional scaling factor to scale positions +/// :param tuple center: An optional center position. This is a 2 tuple of two +/// ``float`` values for the center position +/// :param float aspect_ratio: An optional number for the ratio of the width to +/// the height of the layout. +/// +/// :returns: The bipartite layout of the graph. +/// :rtype: Pos2DMapping +#[pyfunction] +#[text_signature = "(graph, first_nodes, /, horitontal=False, scale=1, + center=None, aspect_ratio=1.33333333333333)"] +pub fn digraph_bipartite_layout( + graph: &digraph::PyDiGraph, + first_nodes: HashSet, + horizontal: Option, + scale: Option, + center: Option, + aspect_ratio: Option, +) -> Pos2DMapping { + layout::bipartite_layout( + &graph.graph, + first_nodes, + horizontal, + scale, + center, + aspect_ratio, + ) +} + +/// Generate a circular layout of the graph +/// +/// :param PyGraph graph: The graph to generate the layout for +/// :param float scale: An optional scaling factor to scale positions +/// :param tuple center: An optional center position. This is a 2 tuple of two +/// ``float`` values for the center position +/// +/// :returns: The circular layout of the graph. +/// :rtype: Pos2DMapping +#[pyfunction] +#[text_signature = "(graph, /, scale=1, center=None)"] +pub fn graph_circular_layout( + graph: &graph::PyGraph, + scale: Option, + center: Option, +) -> Pos2DMapping { + layout::circular_layout(&graph.graph, scale, center) +} + +/// Generate a circular layout of the graph +/// +/// :param PyDiGraph graph: The graph to generate the layout for +/// :param float scale: An optional scaling factor to scale positions +/// :param tuple center: An optional center position. This is a 2 tuple of two +/// ``float`` values for the center position +/// +/// :returns: The circular layout of the graph. +/// :rtype: Pos2DMapping +#[pyfunction] +#[text_signature = "(graph, /, scale=1, center=None)"] +pub fn digraph_circular_layout( + graph: &digraph::PyDiGraph, + scale: Option, + center: Option, +) -> Pos2DMapping { + layout::circular_layout(&graph.graph, scale, center) +} + +/// Generate a shell layout of the graph +/// +/// :param PyGraph graph: The graph to generate the layout for +/// :param list nlist: The list of lists of indexes which represents each shell +/// :param float rotate: Angle (in radians) by which to rotate the starting +/// position of each shell relative to the starting position of the +/// previous shell +/// :param float scale: An optional scaling factor to scale positions +/// :param tuple center: An optional center position. This is a 2 tuple of two +/// ``float`` values for the center position +/// +/// :returns: The shell layout of the graph. +/// :rtype: Pos2DMapping +#[pyfunction] +#[text_signature = "(graph, /, nlist=None, rotate=None, scale=1, center=None)"] +pub fn graph_shell_layout( + graph: &graph::PyGraph, + nlist: Option>>, + rotate: Option, + scale: Option, + center: Option, +) -> Pos2DMapping { + layout::shell_layout(&graph.graph, nlist, rotate, scale, center) +} + +/// Generate a shell layout of the graph +/// +/// :param PyDiGraph graph: The graph to generate the layout for +/// :param list nlist: The list of lists of indexes which represents each shell +/// :param float rotate: Angle by which to rotate the starting position of each shell +/// relative to the starting position of the previous shell (in radians) +/// :param float scale: An optional scaling factor to scale positions +/// :param tuple center: An optional center position. This is a 2 tuple of two +/// ``float`` values for the center position +/// +/// :returns: The shell layout of the graph. +/// :rtype: Pos2DMapping +#[pyfunction] +#[text_signature = "(graph, /, nlist=None, rotate=None, scale=1, center=None)"] +pub fn digraph_shell_layout( + graph: &digraph::PyDiGraph, + nlist: Option>>, + rotate: Option, + scale: Option, + center: Option, +) -> Pos2DMapping { + layout::shell_layout(&graph.graph, nlist, rotate, scale, center) +} + +/// Generate a spiral layout of the graph +/// +/// :param PyGraph graph: The graph to generate the layout for +/// :param float scale: An optional scaling factor to scale positions +/// :param tuple center: An optional center position. This is a 2 tuple of two +/// ``float`` values for the center position +/// :param float resolution: The compactness of the spiral layout returned. +/// Lower values result in more compressed spiral layouts. +/// :param bool equidistant: If true, nodes will be plotted equidistant from +/// each other. +/// +/// :returns: The spiral layout of the graph. +/// :rtype: Pos2DMapping +#[pyfunction] +#[text_signature = "(graph, /, scale=1, center=None, resolution=0.35, + equidistant=False)"] +pub fn graph_spiral_layout( + graph: &graph::PyGraph, + scale: Option, + center: Option, + resolution: Option, + equidistant: Option, +) -> Pos2DMapping { + layout::spiral_layout(&graph.graph, scale, center, resolution, equidistant) +} + +/// Generate a spiral layout of the graph +/// +/// :param PyDiGraph graph: The graph to generate the layout for +/// :param float scale: An optional scaling factor to scale positions +/// :param tuple center: An optional center position. This is a 2 tuple of two +/// ``float`` values for the center position +/// :param float resolution: The compactness of the spiral layout returned. +/// Lower values result in more compressed spiral layouts. +/// :param bool equidistant: If true, nodes will be plotted equidistant from +/// each other. +/// +/// :returns: The spiral layout of the graph. +/// :rtype: Pos2DMapping +#[pyfunction] +#[text_signature = "(graph, /, scale=1, center=None, resolution=0.35, + equidistant=False)"] +pub fn digraph_spiral_layout( + graph: &digraph::PyDiGraph, + scale: Option, + center: Option, + resolution: Option, + equidistant: Option, +) -> Pos2DMapping { + layout::spiral_layout(&graph.graph, scale, center, resolution, equidistant) +} + // The provided node is invalid. create_exception!(retworkx, InvalidNode, PyException); // Performing this operation would result in trying to add a cycle to a DAG. @@ -4319,6 +4530,14 @@ fn retworkx(py: Python<'_>, m: &PyModule) -> PyResult<()> { m.add_wrapped(wrap_pyfunction!(digraph_complement))?; m.add_wrapped(wrap_pyfunction!(graph_random_layout))?; m.add_wrapped(wrap_pyfunction!(digraph_random_layout))?; + m.add_wrapped(wrap_pyfunction!(graph_bipartite_layout))?; + 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_shell_layout))?; + m.add_wrapped(wrap_pyfunction!(digraph_shell_layout))?; + m.add_wrapped(wrap_pyfunction!(graph_spiral_layout))?; + m.add_wrapped(wrap_pyfunction!(digraph_spiral_layout))?; m.add_wrapped(wrap_pyfunction!(graph_spring_layout))?; m.add_wrapped(wrap_pyfunction!(digraph_spring_layout))?; m.add_class::()?; diff --git a/tests/digraph/test_layout.py b/tests/digraph/test_layout.py index a33bca4d87..1892456dac 100644 --- a/tests/digraph/test_layout.py +++ b/tests/digraph/test_layout.py @@ -15,7 +15,25 @@ import retworkx -class TestRandomLayout(unittest.TestCase): +class LayoutTest(unittest.TestCase): + thres = 1e-6 + + def assertLayoutEquiv(self, exp, res): + for k in exp: + ev = exp[k] + rv = res[k] + if ( + abs(ev[0] - rv[0]) > self.thres + or abs(ev[1] - rv[1]) > self.thres + ): + self.fail( + "The position for node %s, %s, differs from the expected " + "position, %s by more than the allowed threshold of %s" + % (k, rv, ev, self.thres) + ) + + +class TestRandomLayout(LayoutTest): def setUp(self): self.graph = retworkx.generators.directed_path_graph(10) @@ -60,3 +78,415 @@ def test_random_layout_no_seed(self): self.assertEqual(len(res), 10) self.assertEqual(len(res[0]), 2) self.assertIsInstance(res[0][0], float) + + +class TestBipartiteLayout(LayoutTest): + def setUp(self): + self.graph = retworkx.generators.directed_path_graph(10) + + def test_bipartite_layout_empty(self): + res = retworkx.bipartite_layout(retworkx.PyDiGraph(), set()) + self.assertEqual({}, res) + + def test_bipartite_layout_hole(self): + g = retworkx.generators.directed_path_graph(5) + g.remove_nodes_from([1]) + res = retworkx.bipartite_layout(g, set()) + expected = { + 0: (0.0, -1.0), + 2: (0.0, -0.3333333333333333), + 3: (0.0, 0.3333333333333333), + 4: (0.0, 1.0), + } + self.assertLayoutEquiv(expected, res) + + def test_bipartite_layout(self): + res = retworkx.bipartite_layout(self.graph, {0, 1, 2, 3, 4}) + expected = { + 0: (-1.0, -0.75), + 1: (-1.0, -0.375), + 2: (-1.0, 0.0), + 3: (-1.0, 0.375), + 4: (-1.0, 0.75), + 5: (1.0, -0.75), + 6: (1.0, -0.375), + 7: (1.0, 0.0), + 8: (1.0, 0.375), + 9: (1.0, 0.75), + } + self.assertLayoutEquiv(expected, res) + + def test_bipartite_layout_horizontal(self): + res = retworkx.bipartite_layout( + self.graph, {0, 1, 2, 3}, horizontal=True + ) + expected = { + 0: (1.0, -0.9), + 1: (0.3333333333333333, -0.9), + 2: (-0.333333333333333, -0.9), + 3: (-1.0, -0.9), + 4: (1.0, 0.6), + 5: (0.6, 0.6), + 6: (0.2, 0.6), + 7: (-0.2, 0.6), + 8: (-0.6, 0.6), + 9: (-1.0, 0.6), + } + self.assertLayoutEquiv(expected, res) + + def test_bipartite_layout_scale(self): + res = retworkx.bipartite_layout(self.graph, {0, 1, 2}, scale=2) + expected = { + 0: (-2.0, -1.0714285714285714), + 1: (-2.0, 2.3790493384824785e-17), + 2: (-2.0, 1.0714285714285714), + 3: (0.8571428571428571, -1.0714285714285714), + 4: (0.8571428571428571, -0.7142857142857143), + 5: (0.8571428571428571, -0.35714285714285715), + 6: (0.8571428571428571, 2.3790493384824785e-17), + 7: (0.8571428571428571, 0.35714285714285704), + 8: (0.8571428571428571, 0.7142857142857141), + 9: (0.8571428571428571, 1.0714285714285714), + } + self.assertLayoutEquiv(expected, res) + + def test_bipartite_layout_center(self): + res = retworkx.bipartite_layout( + self.graph, {4, 5, 6}, center=(0.5, 0.5) + ) + expected = { + 4: (-0.5, -0.0357142857142857), + 5: (-0.5, 0.5), + 6: (-0.5, 1.0357142857142856), + 0: (0.9285714285714286, -0.0357142857142857), + 1: (0.9285714285714286, 0.14285714285714285), + 2: (0.9285714285714286, 0.3214285714285714), + 3: (0.9285714285714286, 0.5), + 7: (0.9285714285714286, 0.6785714285714285), + 8: (0.9285714285714286, 0.857142857142857), + 9: (0.9285714285714286, 1.0357142857142856), + } + self.assertLayoutEquiv(expected, res) + + def test_bipartite_layout_ratio(self): + res = retworkx.bipartite_layout(self.graph, {2, 4, 8}, aspect_ratio=4) + expected = { + 8: [-1.0, 0.17857142857142858], + 2: [-1.0, -0.17857142857142858], + 4: [-1.0, 0], + 0: [0.42857142857142855, -0.17857142857142858], + 1: [0.42857142857142855, -0.11904761904761907], + 3: [0.42857142857142855, -0.05952380952380952], + 5: [0.42857142857142855, 0], + 6: [0.42857142857142855, 0.05952380952380952], + 7: [0.42857142857142855, 0.11904761904761903], + 9: [0.42857142857142855, 0.17857142857142858], + } + self.assertLayoutEquiv(expected, res) + + +class TestCircularLayout(LayoutTest): + def setUp(self): + self.graph = retworkx.generators.directed_path_graph(10) + + def test_circular_layout_empty(self): + res = retworkx.circular_layout(retworkx.PyDiGraph()) + self.assertEqual({}, res) + + def test_circular_layout_one_node(self): + res = retworkx.circular_layout( + retworkx.generators.directed_path_graph(1) + ) + self.assertEqual({0: (0.0, 0.0)}, res) + + def test_circular_layout_hole(self): + g = retworkx.generators.directed_path_graph(5) + g.remove_nodes_from([1]) + res = retworkx.circular_layout(g) + expected = { + 0: (0.999999986090933, 2.1855693665697608e-08), + 2: (-3.576476059301554e-08, 1.0), + 3: (-0.9999999701976796, -6.556708099709282e-08), + 4: (1.987150711625619e-08, -0.9999999562886126), + } + self.assertLayoutEquiv(expected, res) + + def test_circular_layout(self): + res = retworkx.circular_layout(self.graph) + expected = { + 0: (1.0, 2.662367085193061e-08), + 1: (0.8090170042900712, 0.5877852653564984), + 2: (0.3090169789580973, 0.9510565581329226), + 3: (-0.3090170206813483, 0.9510564985282783), + 4: (-0.8090170460133221, 0.5877852057518542), + 5: (-0.9999999821186069, -6.079910493992474e-08), + 6: (-0.8090169268040337, -0.5877853313184453), + 7: (-0.3090170802859925, -0.9510564452809367), + 8: (0.3090171279697079, -0.9510564452809367), + 9: (0.809016944685427, -0.587785271713801), + } + self.assertLayoutEquiv(expected, res) + + def test_circular_layout_scale(self): + res = retworkx.circular_layout(self.graph, scale=2) + expected = { + 0: (2.0, 5.324734170386122e-08), + 1: (1.6180340085801423, 1.1755705307129969), + 2: (0.6180339579161946, 1.9021131162658451), + 3: (-0.6180340413626966, 1.9021129970565567), + 4: (-1.6180340920266443, 1.1755704115037084), + 5: (-1.9999999642372137, -1.2159820987984948e-07), + 6: (-1.6180338536080674, -1.1755706626368907), + 7: (-0.618034160571985, -1.9021128905618734), + 8: (0.6180342559394159, -1.9021128905618734), + 9: (1.618033889370854, -1.175570543427602), + } + self.assertLayoutEquiv(expected, res) + + def test_circular_layout_center(self): + res = retworkx.circular_layout(self.graph, center=(0.5, 0.5)) + expected = { + 0: (1.5, 0.5000000266236708), + 1: (1.3090170042900713, 1.0877852653564983), + 2: (0.8090169789580973, 1.4510565581329224), + 3: (0.1909829793186517, 1.4510564985282783), + 4: (-0.30901704601332214, 1.0877852057518542), + 5: (-0.49999998211860686, 0.4999999392008951), + 6: (-0.3090169268040337, -0.08778533131844535), + 7: (0.1909829197140075, -0.4510564452809367), + 8: (0.8090171279697079, -0.4510564452809367), + 9: (1.309016944685427, -0.08778527171380102), + } + self.assertLayoutEquiv(expected, res) + + +class TestShellLayout(LayoutTest): + def setUp(self): + self.graph = retworkx.generators.directed_path_graph(10) + + def test_shell_layout_empty(self): + res = retworkx.circular_layout(retworkx.PyDiGraph()) + self.assertEqual({}, res) + + def test_shell_layout_one_node(self): + res = retworkx.shell_layout(retworkx.generators.directed_path_graph(1)) + self.assertEqual({0: (0.0, 0.0)}, res) + + def test_shell_layout_hole(self): + g = retworkx.generators.directed_path_graph(5) + g.remove_nodes_from([1]) + res = retworkx.shell_layout(g) + expected = { + 0: (-1.0, -8.742277657347586e-08), + 2: (1.1924880638503055e-08, -1.0), + 3: (1.0, 1.7484555314695172e-07), + 4: (-3.3776623808989825e-07, 1.0), + } + self.assertLayoutEquiv(expected, res) + + def test_shell_layout_hole_two_shells(self): + g = retworkx.generators.directed_path_graph(5) + g.remove_nodes_from([2]) + res = retworkx.shell_layout(g, [[0, 1], [3, 4]]) + expected = { + 0: (-2.1855694143368964e-08, 0.5), + 1: (5.962440319251527e-09, -0.5), + 3: (-1.0, -8.742277657347586e-08), + 4: (1.0, 1.7484555314695172e-07), + } + self.assertLayoutEquiv(expected, res) + + def test_shell_layout(self): + res = retworkx.shell_layout(self.graph) + expected = { + 0: (-1.0, -8.742277657347586e-08), + 1: (-0.8090169429779053, -0.5877853631973267), + 2: (-0.3090170919895172, -0.9510564804077148), + 3: (0.3090171217918396, -0.9510564804077148), + 4: (0.8090172410011292, -0.5877849459648132), + 5: (1.0, 1.7484555314695172e-07), + 6: (0.80901700258255, 0.5877852439880371), + 7: (0.30901679396629333, 0.9510565996170044), + 8: (-0.30901744961738586, 0.9510563611984253), + 9: (-0.8090168833732605, 0.5877854228019714), + } + self.assertLayoutEquiv(expected, res) + + def test_shell_layout_nlist(self): + res = retworkx.shell_layout( + self.graph, nlist=[[0, 2], [1, 3], [4, 9], [8, 7], [6, 5]] + ) + expected = { + 0: (0.16180340945720673, 0.11755704879760742), + 2: (-0.16180339455604553, -0.11755707114934921), + 1: (0.12360679358243942, 0.3804226219654083), + 3: (-0.123606838285923, -0.38042259216308594), + 4: (-0.18541023135185242, 0.5706338882446289), + 9: (0.185410276055336, -0.5706338882446289), + 8: (-0.6472136378288269, 0.4702281653881073), + 7: (0.6472138166427612, -0.4702279567718506), + 6: (-1.0, -8.742277657347586e-08), + 5: (1.0, 1.7484555314695172e-07), + } + self.assertLayoutEquiv(expected, res) + + def test_shell_layout_rotate(self): + res = retworkx.shell_layout( + self.graph, nlist=[[0, 1, 2], [3, 4, 5], [6, 7, 8], [9]], rotate=0.5 + ) + expected = { + 0: (0.21939563751220703, 0.11985638737678528), + 1: (-0.21349650621414185, 0.13007399439811707), + 2: (-0.005899117328226566, -0.24993039667606354), + 3: (0.27015113830566406, 0.4207354784011841), + 4: (-0.4994432032108307, 0.023589985445141792), + 5: (0.229292094707489, -0.4443254768848419), + 6: (0.05305289849638939, 0.7481212615966797), + 7: (-0.6744184494018555, -0.3281154930591583), + 8: (0.6213656067848206, -0.420005738735199), + 9: (-0.416146844625473, 0.9092974066734314), + } + self.assertLayoutEquiv(expected, res) + + def test_shell_layout_scale(self): + res = retworkx.shell_layout( + self.graph, nlist=[[0, 1, 2, 3, 4], [9, 8, 7, 6, 5]], scale=2 + ) + expected = { + 0: (-4.371138828673793e-08, 1.0), + 1: (-0.9510565996170044, 0.30901679396629333), + 2: (-0.5877850651741028, -0.8090171217918396), + 3: (0.5877854824066162, -0.8090168237686157), + 4: (0.9510564208030701, 0.30901727080345154), + 9: (-2.0, -1.7484555314695172e-07), + 8: (-0.6180341839790344, -1.9021129608154297), + 7: (1.6180344820022583, -1.1755698919296265), + 6: (1.6180340051651, 1.1755704879760742), + 5: (-0.6180348992347717, 1.9021127223968506), + } + self.assertLayoutEquiv(expected, res) + + def test_shell_layout_center(self): + res = retworkx.shell_layout( + self.graph, + nlist=[[0, 1, 2, 3, 4], [9, 8, 7, 6, 5]], + center=(0.5, 0.5), + ) + expected = { + 0: (0.49999997814430586, 1.0), + 1: (0.024471700191497803, 0.6545083969831467), + 2: (0.2061074674129486, 0.0954914391040802), + 3: (0.7938927412033081, 0.09549158811569214), + 4: (0.975528210401535, 0.6545086354017258), + 9: (-0.5, 0.4999999125772234), + 8: (0.1909829080104828, -0.45105648040771484), + 7: (1.3090172410011292, -0.08778494596481323), + 6: (1.30901700258255, 1.087785243988037), + 5: (0.19098255038261414, 1.4510563611984253), + } + self.assertLayoutEquiv(expected, res) + + +class TestSpiralLayout(LayoutTest): + def setUp(self): + self.graph = retworkx.generators.directed_path_graph(10) + + def test_spiral_layout_empty(self): + res = retworkx.spiral_layout(retworkx.PyDiGraph()) + self.assertEqual({}, res) + + def test_spiral_layout_one_node(self): + res = retworkx.spiral_layout(retworkx.generators.directed_path_graph(1)) + self.assertEqual({0: (0.0, 0.0)}, res) + + def test_spiral_layout_hole(self): + g = retworkx.generators.directed_path_graph(5) + g.remove_nodes_from([1]) + res = retworkx.spiral_layout(g) + expected = { + 0: (-0.6415327868391166, -0.6855508729419231), + 2: (-0.03307913182988828, -0.463447951079834), + 3: (0.34927952438480797, 0.1489988240217569), + 4: (0.32533239428419697, 1.0), + } + self.assertLayoutEquiv(expected, res) + + def test_spiral_layout(self): + res = retworkx.spiral_layout(self.graph) + expected = { + 0: (0.3083011152777303, -0.36841870322845377), + 1: (0.4448595378922136, -0.3185709877650719), + 2: (0.5306742824266687, -0.18111636841212878), + 3: (0.5252997033017661, 0.009878257518578544), + 4: (0.40713492048969163, 0.20460820654918466), + 5: (0.17874125121181098, 0.3468009691240852), + 6: (-0.1320415949011884, 0.3844997574641717), + 7: (-0.4754889029311045, 0.28057288841663486), + 8: (-0.7874803127675889, 0.021164283410983312), + 9: (-0.9999999999999999, -0.3794183030779839), + } + self.assertLayoutEquiv(expected, res) + + def test_spiral_layout_scale(self): + res = retworkx.spiral_layout(self.graph, scale=2) + expected = { + 0: (0.6166022305554606, -0.7368374064569075), + 1: (0.8897190757844272, -0.6371419755301438), + 2: (1.0613485648533374, -0.36223273682425755), + 3: (1.0505994066035322, 0.01975651503715709), + 4: (0.8142698409793833, 0.4092164130983693), + 5: (0.35748250242362195, 0.6936019382481704), + 6: (-0.2640831898023768, 0.7689995149283434), + 7: (-0.950977805862209, 0.5611457768332697), + 8: (-1.5749606255351778, 0.042328566821966625), + 9: (-1.9999999999999998, -0.7588366061559678), + } + self.assertLayoutEquiv(expected, res) + + def test_spiral_layout_center(self): + res = retworkx.spiral_layout(self.graph, center=(1, 1)) + expected = { + 0: (1.3083011152777302, 0.6315812967715462), + 1: (1.4448595378922136, 0.681429012234928), + 2: (1.5306742824266686, 0.8188836315878713), + 3: (1.5252997033017661, 1.0098782575185785), + 4: (1.4071349204896917, 1.2046082065491848), + 5: (1.178741251211811, 1.3468009691240852), + 6: (0.8679584050988116, 1.3844997574641718), + 7: (0.5245110970688955, 1.2805728884166347), + 8: (0.2125196872324111, 1.0211642834109833), + 9: (1.1102230246251565e-16, 0.6205816969220161), + } + self.assertLayoutEquiv(expected, res) + + def test_spiral_layout_resolution(self): + res = retworkx.spiral_layout(self.graph, resolution=0.6) + expected = { + 0: (0.14170895375949058, 0.22421978768273812), + 1: (0.2657196183870804, 0.30906004798138936), + 2: (0.2506009612140119, 0.5043065412934762), + 3: (0.039294315670400995, 0.6631957258449066), + 4: (-0.3014789232909145, 0.6301862160709318), + 5: (-0.602046830323471, 0.3302396035952633), + 6: (-0.66674476042188, -0.17472522299849289), + 7: (-0.3739394496041176, -0.6924895145748617), + 8: (0.2468861146093996, -0.9732085843739783), + 9: (1.0, -0.8207846005213728), + } + self.assertLayoutEquiv(expected, res) + + def test_spiral_layout_equidistant(self): + res = retworkx.spiral_layout(self.graph, equidistant=True) + expected = { + 0: (-0.13161882865656718, -0.7449342807652114), + 1: (0.7160560542246066, -0.6335352483233974), + 2: (0.6864868274284994, -0.34165899654603915), + 3: (0.5679822628330004, -0.07281296883784087), + 4: (0.375237081214659, 0.14941210155952697), + 5: (0.12730720268992277, 0.30830226777240866), + 6: (-0.15470865936858091, 0.3939608192236113), + 7: (-0.4495426197217269, 0.4027809258196645), + 8: (-0.7371993206438128, 0.33662707199446507), + 9: (-1.0, 0.2018583081028111), + } + self.assertLayoutEquiv(expected, res) diff --git a/tests/graph/test_layout.py b/tests/graph/test_layout.py index d8c2bc9003..c88aa582f7 100644 --- a/tests/graph/test_layout.py +++ b/tests/graph/test_layout.py @@ -15,7 +15,25 @@ import retworkx -class TestRandomLayout(unittest.TestCase): +class LayoutTest(unittest.TestCase): + thres = 1e-6 + + def assertLayoutEquiv(self, exp, res): + for k in exp: + ev = exp[k] + rv = res[k] + if ( + abs(ev[0] - rv[0]) > self.thres + or abs(ev[1] - rv[1]) > self.thres + ): + self.fail( + "The position for node %s, %s, differs from the expected " + "position, %s by more than the allowed threshold of %s" + % (k, rv, ev, self.thres) + ) + + +class TestRandomLayout(LayoutTest): def setUp(self): self.graph = retworkx.generators.path_graph(10) @@ -60,3 +78,413 @@ def test_random_layout_no_seed(self): self.assertEqual(len(res), 10) self.assertEqual(len(res[0]), 2) self.assertIsInstance(res[0][0], float) + + +class TestBipartiteLayout(LayoutTest): + def setUp(self): + self.graph = retworkx.generators.path_graph(10) + + def test_bipartite_layout_empty(self): + res = retworkx.bipartite_layout(retworkx.PyGraph(), set()) + self.assertEqual({}, res) + + def test_bipartite_layout_hole(self): + g = retworkx.generators.path_graph(5) + g.remove_nodes_from([1]) + res = retworkx.bipartite_layout(g, set()) + expected = { + 0: (0.0, -1.0), + 2: (0.0, -0.3333333333333333), + 3: (0.0, 0.3333333333333333), + 4: (0.0, 1.0), + } + self.assertLayoutEquiv(expected, res) + + def test_bipartite_layout(self): + res = retworkx.bipartite_layout(self.graph, {0, 1, 2, 3, 4}) + expected = { + 0: (-1.0, -0.75), + 1: (-1.0, -0.375), + 2: (-1.0, 0.0), + 3: (-1.0, 0.375), + 4: (-1.0, 0.75), + 5: (1.0, -0.75), + 6: (1.0, -0.375), + 7: (1.0, 0.0), + 8: (1.0, 0.375), + 9: (1.0, 0.75), + } + self.assertLayoutEquiv(expected, res) + + def test_bipartite_layout_horizontal(self): + res = retworkx.bipartite_layout( + self.graph, {0, 1, 2, 3}, horizontal=True + ) + expected = { + 0: (1.0, -0.9), + 1: (0.3333333333333333, -0.9), + 2: (-0.333333333333333, -0.9), + 3: (-1.0, -0.9), + 4: (1.0, 0.6), + 5: (0.6, 0.6), + 6: (0.2, 0.6), + 7: (-0.2, 0.6), + 8: (-0.6, 0.6), + 9: (-1.0, 0.6), + } + self.assertLayoutEquiv(expected, res) + + def test_bipartite_layout_scale(self): + res = retworkx.bipartite_layout(self.graph, {0, 1, 2}, scale=2) + expected = { + 0: (-2.0, -1.0714285714285714), + 1: (-2.0, 2.3790493384824785e-17), + 2: (-2.0, 1.0714285714285714), + 3: (0.8571428571428571, -1.0714285714285714), + 4: (0.8571428571428571, -0.7142857142857143), + 5: (0.8571428571428571, -0.35714285714285715), + 6: (0.8571428571428571, 2.3790493384824785e-17), + 7: (0.8571428571428571, 0.35714285714285704), + 8: (0.8571428571428571, 0.7142857142857141), + 9: (0.8571428571428571, 1.0714285714285714), + } + self.assertLayoutEquiv(expected, res) + + def test_bipartite_layout_center(self): + res = retworkx.bipartite_layout( + self.graph, {4, 5, 6}, center=(0.5, 0.5) + ) + expected = { + 4: (-0.5, -0.0357142857142857), + 5: (-0.5, 0.5), + 6: (-0.5, 1.0357142857142856), + 0: (0.9285714285714286, -0.0357142857142857), + 1: (0.9285714285714286, 0.14285714285714285), + 2: (0.9285714285714286, 0.3214285714285714), + 3: (0.9285714285714286, 0.5), + 7: (0.9285714285714286, 0.6785714285714285), + 8: (0.9285714285714286, 0.857142857142857), + 9: (0.9285714285714286, 1.0357142857142856), + } + self.assertLayoutEquiv(expected, res) + + def test_bipartite_layout_ratio(self): + res = retworkx.bipartite_layout(self.graph, {2, 4, 8}, aspect_ratio=4) + expected = { + 8: [-1.0, 0.17857142857142858], + 2: [-1.0, -0.17857142857142858], + 4: [-1.0, 0], + 0: [0.42857142857142855, -0.17857142857142858], + 1: [0.42857142857142855, -0.11904761904761907], + 3: [0.42857142857142855, -0.05952380952380952], + 5: [0.42857142857142855, 0], + 6: [0.42857142857142855, 0.05952380952380952], + 7: [0.42857142857142855, 0.11904761904761903], + 9: [0.42857142857142855, 0.17857142857142858], + } + self.assertLayoutEquiv(expected, res) + + +class TestCircularLayout(LayoutTest): + def setUp(self): + self.graph = retworkx.generators.path_graph(10) + + def test_circular_layout_empty(self): + res = retworkx.circular_layout(retworkx.PyGraph()) + self.assertEqual({}, res) + + def test_circular_layout_one_node(self): + res = retworkx.circular_layout(retworkx.generators.path_graph(1)) + self.assertEqual({0: (0.0, 0.0)}, res) + + def test_circular_layout_hole(self): + g = retworkx.generators.path_graph(5) + g.remove_nodes_from([1]) + res = retworkx.circular_layout(g) + expected = { + 0: (0.999999986090933, 2.1855693665697608e-08), + 2: (-3.576476059301554e-08, 1.0), + 3: (-0.9999999701976796, -6.556708099709282e-08), + 4: (1.987150711625619e-08, -0.9999999562886126), + } + self.assertLayoutEquiv(expected, res) + + def test_circular_layout(self): + res = retworkx.circular_layout(self.graph) + expected = { + 0: (1.0, 2.662367085193061e-08), + 1: (0.8090170042900712, 0.5877852653564984), + 2: (0.3090169789580973, 0.9510565581329226), + 3: (-0.3090170206813483, 0.9510564985282783), + 4: (-0.8090170460133221, 0.5877852057518542), + 5: (-0.9999999821186069, -6.079910493992474e-08), + 6: (-0.8090169268040337, -0.5877853313184453), + 7: (-0.3090170802859925, -0.9510564452809367), + 8: (0.3090171279697079, -0.9510564452809367), + 9: (0.809016944685427, -0.587785271713801), + } + self.assertLayoutEquiv(expected, res) + + def test_circular_layout_scale(self): + res = retworkx.circular_layout(self.graph, scale=2) + expected = { + 0: (2.0, 5.324734170386122e-08), + 1: (1.6180340085801423, 1.1755705307129969), + 2: (0.6180339579161946, 1.9021131162658451), + 3: (-0.6180340413626966, 1.9021129970565567), + 4: (-1.6180340920266443, 1.1755704115037084), + 5: (-1.9999999642372137, -1.2159820987984948e-07), + 6: (-1.6180338536080674, -1.1755706626368907), + 7: (-0.618034160571985, -1.9021128905618734), + 8: (0.6180342559394159, -1.9021128905618734), + 9: (1.618033889370854, -1.175570543427602), + } + self.assertLayoutEquiv(expected, res) + + def test_circular_layout_center(self): + res = retworkx.circular_layout(self.graph, center=(0.5, 0.5)) + expected = { + 0: (1.5, 0.5000000266236708), + 1: (1.3090170042900713, 1.0877852653564983), + 2: (0.8090169789580973, 1.4510565581329224), + 3: (0.1909829793186517, 1.4510564985282783), + 4: (-0.30901704601332214, 1.0877852057518542), + 5: (-0.49999998211860686, 0.4999999392008951), + 6: (-0.3090169268040337, -0.08778533131844535), + 7: (0.1909829197140075, -0.4510564452809367), + 8: (0.8090171279697079, -0.4510564452809367), + 9: (1.309016944685427, -0.08778527171380102), + } + self.assertLayoutEquiv(expected, res) + + +class TestShellLayout(LayoutTest): + def setUp(self): + self.graph = retworkx.generators.path_graph(10) + + def test_shell_layout_empty(self): + res = retworkx.circular_layout(retworkx.PyGraph()) + self.assertEqual({}, res) + + def test_shell_layout_one_node(self): + res = retworkx.shell_layout(retworkx.generators.path_graph(1)) + self.assertEqual({0: (0.0, 0.0)}, res) + + def test_shell_layout_hole(self): + g = retworkx.generators.path_graph(5) + g.remove_nodes_from([1]) + res = retworkx.shell_layout(g) + expected = { + 0: (-1.0, -8.742277657347586e-08), + 2: (1.1924880638503055e-08, -1.0), + 3: (1.0, 1.7484555314695172e-07), + 4: (-3.3776623808989825e-07, 1.0), + } + self.assertLayoutEquiv(expected, res) + + def test_shell_layout_hole_two_shells(self): + g = retworkx.generators.path_graph(5) + g.remove_nodes_from([2]) + res = retworkx.shell_layout(g, [[0, 1], [3, 4]]) + expected = { + 0: (-2.1855694143368964e-08, 0.5), + 1: (5.962440319251527e-09, -0.5), + 3: (-1.0, -8.742277657347586e-08), + 4: (1.0, 1.7484555314695172e-07), + } + self.assertLayoutEquiv(expected, res) + + def test_shell_layout(self): + res = retworkx.shell_layout(self.graph) + expected = { + 0: (-1.0, -8.742277657347586e-08), + 1: (-0.8090169429779053, -0.5877853631973267), + 2: (-0.3090170919895172, -0.9510564804077148), + 3: (0.3090171217918396, -0.9510564804077148), + 4: (0.8090172410011292, -0.5877849459648132), + 5: (1.0, 1.7484555314695172e-07), + 6: (0.80901700258255, 0.5877852439880371), + 7: (0.30901679396629333, 0.9510565996170044), + 8: (-0.30901744961738586, 0.9510563611984253), + 9: (-0.8090168833732605, 0.5877854228019714), + } + self.assertLayoutEquiv(expected, res) + + def test_shell_layout_nlist(self): + res = retworkx.shell_layout( + self.graph, nlist=[[0, 2], [1, 3], [4, 9], [8, 7], [6, 5]] + ) + expected = { + 0: (0.16180340945720673, 0.11755704879760742), + 2: (-0.16180339455604553, -0.11755707114934921), + 1: (0.12360679358243942, 0.3804226219654083), + 3: (-0.123606838285923, -0.38042259216308594), + 4: (-0.18541023135185242, 0.5706338882446289), + 9: (0.185410276055336, -0.5706338882446289), + 8: (-0.6472136378288269, 0.4702281653881073), + 7: (0.6472138166427612, -0.4702279567718506), + 6: (-1.0, -8.742277657347586e-08), + 5: (1.0, 1.7484555314695172e-07), + } + self.assertLayoutEquiv(expected, res) + + def test_shell_layout_rotate(self): + res = retworkx.shell_layout( + self.graph, nlist=[[0, 1, 2], [3, 4, 5], [6, 7, 8], [9]], rotate=0.5 + ) + expected = { + 0: (0.21939563751220703, 0.11985638737678528), + 1: (-0.21349650621414185, 0.13007399439811707), + 2: (-0.005899117328226566, -0.24993039667606354), + 3: (0.27015113830566406, 0.4207354784011841), + 4: (-0.4994432032108307, 0.023589985445141792), + 5: (0.229292094707489, -0.4443254768848419), + 6: (0.05305289849638939, 0.7481212615966797), + 7: (-0.6744184494018555, -0.3281154930591583), + 8: (0.6213656067848206, -0.420005738735199), + 9: (-0.416146844625473, 0.9092974066734314), + } + self.assertLayoutEquiv(expected, res) + + def test_shell_layout_scale(self): + res = retworkx.shell_layout( + self.graph, nlist=[[0, 1, 2, 3, 4], [9, 8, 7, 6, 5]], scale=2 + ) + expected = { + 0: (-4.371138828673793e-08, 1.0), + 1: (-0.9510565996170044, 0.30901679396629333), + 2: (-0.5877850651741028, -0.8090171217918396), + 3: (0.5877854824066162, -0.8090168237686157), + 4: (0.9510564208030701, 0.30901727080345154), + 9: (-2.0, -1.7484555314695172e-07), + 8: (-0.6180341839790344, -1.9021129608154297), + 7: (1.6180344820022583, -1.1755698919296265), + 6: (1.6180340051651, 1.1755704879760742), + 5: (-0.6180348992347717, 1.9021127223968506), + } + self.assertLayoutEquiv(expected, res) + + def test_shell_layout_center(self): + res = retworkx.shell_layout( + self.graph, + nlist=[[0, 1, 2, 3, 4], [9, 8, 7, 6, 5]], + center=(0.5, 0.5), + ) + expected = { + 0: (0.49999997814430586, 1.0), + 1: (0.024471700191497803, 0.6545083969831467), + 2: (0.2061074674129486, 0.0954914391040802), + 3: (0.7938927412033081, 0.09549158811569214), + 4: (0.975528210401535, 0.6545086354017258), + 9: (-0.5, 0.4999999125772234), + 8: (0.1909829080104828, -0.45105648040771484), + 7: (1.3090172410011292, -0.08778494596481323), + 6: (1.30901700258255, 1.087785243988037), + 5: (0.19098255038261414, 1.4510563611984253), + } + self.assertLayoutEquiv(expected, res) + + +class TestSpiralLayout(LayoutTest): + def setUp(self): + self.graph = retworkx.generators.path_graph(10) + + def test_spiral_layout_empty(self): + res = retworkx.spiral_layout(retworkx.PyGraph()) + self.assertEqual({}, res) + + def test_spiral_layout_one_node(self): + res = retworkx.spiral_layout(retworkx.generators.path_graph(1)) + self.assertEqual({0: (0.0, 0.0)}, res) + + def test_spiral_layout_hole(self): + g = retworkx.generators.path_graph(5) + g.remove_nodes_from([1]) + res = retworkx.spiral_layout(g) + expected = { + 0: (-0.6415327868391166, -0.6855508729419231), + 2: (-0.03307913182988828, -0.463447951079834), + 3: (0.34927952438480797, 0.1489988240217569), + 4: (0.32533239428419697, 1.0), + } + self.assertLayoutEquiv(expected, res) + + def test_spiral_layout(self): + res = retworkx.spiral_layout(self.graph) + expected = { + 0: (0.3083011152777303, -0.36841870322845377), + 1: (0.4448595378922136, -0.3185709877650719), + 2: (0.5306742824266687, -0.18111636841212878), + 3: (0.5252997033017661, 0.009878257518578544), + 4: (0.40713492048969163, 0.20460820654918466), + 5: (0.17874125121181098, 0.3468009691240852), + 6: (-0.1320415949011884, 0.3844997574641717), + 7: (-0.4754889029311045, 0.28057288841663486), + 8: (-0.7874803127675889, 0.021164283410983312), + 9: (-0.9999999999999999, -0.3794183030779839), + } + self.assertLayoutEquiv(expected, res) + + def test_spiral_layout_scale(self): + res = retworkx.spiral_layout(self.graph, scale=2) + expected = { + 0: (0.6166022305554606, -0.7368374064569075), + 1: (0.8897190757844272, -0.6371419755301438), + 2: (1.0613485648533374, -0.36223273682425755), + 3: (1.0505994066035322, 0.01975651503715709), + 4: (0.8142698409793833, 0.4092164130983693), + 5: (0.35748250242362195, 0.6936019382481704), + 6: (-0.2640831898023768, 0.7689995149283434), + 7: (-0.950977805862209, 0.5611457768332697), + 8: (-1.5749606255351778, 0.042328566821966625), + 9: (-1.9999999999999998, -0.7588366061559678), + } + self.assertLayoutEquiv(expected, res) + + def test_spiral_layout_center(self): + res = retworkx.spiral_layout(self.graph, center=(1, 1)) + expected = { + 0: (1.3083011152777302, 0.6315812967715462), + 1: (1.4448595378922136, 0.681429012234928), + 2: (1.5306742824266686, 0.8188836315878713), + 3: (1.5252997033017661, 1.0098782575185785), + 4: (1.4071349204896917, 1.2046082065491848), + 5: (1.178741251211811, 1.3468009691240852), + 6: (0.8679584050988116, 1.3844997574641718), + 7: (0.5245110970688955, 1.2805728884166347), + 8: (0.2125196872324111, 1.0211642834109833), + 9: (1.1102230246251565e-16, 0.6205816969220161), + } + self.assertLayoutEquiv(expected, res) + + def test_spiral_layout_resolution(self): + res = retworkx.spiral_layout(self.graph, resolution=0.6) + expected = { + 0: (0.14170895375949058, 0.22421978768273812), + 1: (0.2657196183870804, 0.30906004798138936), + 2: (0.2506009612140119, 0.5043065412934762), + 3: (0.039294315670400995, 0.6631957258449066), + 4: (-0.3014789232909145, 0.6301862160709318), + 5: (-0.602046830323471, 0.3302396035952633), + 6: (-0.66674476042188, -0.17472522299849289), + 7: (-0.3739394496041176, -0.6924895145748617), + 8: (0.2468861146093996, -0.9732085843739783), + 9: (1.0, -0.8207846005213728), + } + self.assertLayoutEquiv(expected, res) + + def test_spiral_layout_equidistant(self): + res = retworkx.spiral_layout(self.graph, equidistant=True) + expected = { + 0: (-0.13161882865656718, -0.7449342807652114), + 1: (0.7160560542246066, -0.6335352483233974), + 2: (0.6864868274284994, -0.34165899654603915), + 3: (0.5679822628330004, -0.07281296883784087), + 4: (0.375237081214659, 0.14941210155952697), + 5: (0.12730720268992277, 0.30830226777240866), + 6: (-0.15470865936858091, 0.3939608192236113), + 7: (-0.4495426197217269, 0.4027809258196645), + 8: (-0.7371993206438128, 0.33662707199446507), + 9: (-1.0, 0.2018583081028111), + } + self.assertLayoutEquiv(expected, res)