diff --git a/cereeberus/cereeberus/draw/layout.py b/cereeberus/cereeberus/draw/layout.py index 918d9f2..18142c5 100644 --- a/cereeberus/cereeberus/draw/layout.py +++ b/cereeberus/cereeberus/draw/layout.py @@ -105,6 +105,14 @@ def reeb_x_layout(G, f, seed=None, repulsion=0.5): Returns: dict: Mapping node -> x-position. """ + + def _normalize_to_unit_interval(x): + """Normalize coordinates to [-1, 1] if there is nonzero spread.""" + x_range = x.max() - x.min() + if x_range > 1e-9: + return 2 * (x - x.min()) / x_range - 1 + return x + nodes = list(G.nodes) n = len(nodes) if n == 0: @@ -135,9 +143,23 @@ def reeb_x_layout(G, f, seed=None, repulsion=0.5): # Initialise with barycenter ordering, then add tiny jitter to break ties x0 = _barycenter_init(n, f_vals, edges, level_nodes) + + # With no edges, the spring term is absent and repulsion alone can drive + # points apart indefinitely. In this case, return the barycenter layout + # directly (normalised), which is deterministic and finite. + if len(edges) == 0: + x0 = _normalize_to_unit_interval(x0) + return {v: float(x0[idx[v]]) for v in nodes} + rng = np.random.default_rng(seed) x0 = x0 + rng.standard_normal(n) * 1e-3 + # Keep the optimizer in a compact region and add a tiny centering term so + # the objective remains well-conditioned. + bounds = [(-2.0, 2.0)] * n + center_weight = 1e-6 + x0 = np.clip(x0, -2.0, 2.0) + def energy(x): e = 0.0 for i, j in edges: @@ -146,6 +168,7 @@ def energy(x): for i, j in same_height_pairs: diff = x[i] - x[j] e += repulsion / (diff**2 + 1e-6) + e += center_weight * np.dot(x, x) return e def gradient(x): @@ -161,14 +184,13 @@ def gradient(x): grad_val = -2 * repulsion * diff / denom g[i] += grad_val g[j] -= grad_val + g += 2 * center_weight * x return g - result = minimize(energy, x0, jac=gradient, method="L-BFGS-B") + result = minimize(energy, x0, jac=gradient, method="L-BFGS-B", bounds=bounds) x_opt = result.x # Normalise to [-1, 1] - x_range = x_opt.max() - x_opt.min() - if x_range > 1e-9: - x_opt = 2 * (x_opt - x_opt.min()) / x_range - 1 + x_opt = _normalize_to_unit_interval(x_opt) return {v: float(x_opt[idx[v]]) for v in nodes} diff --git a/doc_source/images/line.png b/doc_source/images/line.png index 24939ba..62f6490 100644 Binary files a/doc_source/images/line.png and b/doc_source/images/line.png differ diff --git a/doc_source/images/torus-extraverts.png b/doc_source/images/torus-extraverts.png index 9dc36ef..c3e71da 100644 Binary files a/doc_source/images/torus-extraverts.png and b/doc_source/images/torus-extraverts.png differ diff --git a/pyproject.toml b/pyproject.toml index 064dc66..ea0c6e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "cereeberus" -version = "0.1.13" +version = "0.1.14" authors = [ { name="Liz Munch", email="muncheli@msu.edu" }, ] diff --git a/tests/test_draw_layout.py b/tests/test_draw_layout.py new file mode 100644 index 0000000..f9da6b1 --- /dev/null +++ b/tests/test_draw_layout.py @@ -0,0 +1,53 @@ +import unittest + +import networkx as nx +import numpy as np +from cereeberus.data import ex_reebgraphs as ex_rg +from cereeberus.draw.layout import reeb_x_layout + + +class TestDrawLayout(unittest.TestCase): + def test_reeb_x_layout_returns_finite_x_for_all_nodes(self): + R = ex_rg.torus(multigraph=False, seed=0) + + x_positions = reeb_x_layout(R, R.f, seed=17, repulsion=0.8) + + self.assertEqual(set(x_positions.keys()), set(R.nodes)) + for v in R.nodes: + self.assertTrue(np.isfinite(x_positions[v])) + self.assertLessEqual(x_positions[v], 1.0 + 1e-9) + self.assertGreaterEqual(x_positions[v], -1.0 - 1e-9) + + def test_reeb_x_layout_is_reproducible_with_seed(self): + R = ex_rg.torus(multigraph=False, seed=0) + + x_a = reeb_x_layout(R, R.f, seed=123, repulsion=0.8) + x_b = reeb_x_layout(R, R.f, seed=123, repulsion=0.8) + + for v in R.nodes: + self.assertAlmostEqual(x_a[v], x_b[v]) + + def test_reeb_x_layout_empty_graph(self): + G = nx.Graph() + x_positions = reeb_x_layout(G, {}, seed=1, repulsion=0.5) + self.assertEqual(x_positions, {}) + + def test_reeb_x_layout_same_height_isolated_nodes(self): + # Regression test: no edges + same-height nodes should not trigger + # unbounded optimisation drift. + G = nx.Graph() + nodes = ["u", "v", "w", "z"] + G.add_nodes_from(nodes) + f = {node: 0.0 for node in nodes} + + x_positions = reeb_x_layout(G, f, seed=9, repulsion=1.0) + + self.assertEqual(set(x_positions.keys()), set(nodes)) + for node in nodes: + self.assertTrue(np.isfinite(x_positions[node])) + self.assertLessEqual(x_positions[node], 1.0 + 1e-9) + self.assertGreaterEqual(x_positions[node], -1.0 - 1e-9) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mapper_class.py b/tests/test_mapper_class.py index 7f20c5e..d9ab4f6 100644 --- a/tests/test_mapper_class.py +++ b/tests/test_mapper_class.py @@ -1,9 +1,12 @@ import unittest -from cereeberus import ReebGraph, MapperGraph + +import numpy as np from cereeberus.data import ex_graphs as ex_g -from cereeberus.data import ex_reebgraphs as ex_rg from cereeberus.data import ex_mappergraphs as ex_mg -import numpy as np +from cereeberus.data import ex_reebgraphs as ex_rg + +from cereeberus import MapperGraph, ReebGraph + class TestMapperClass(unittest.TestCase): @@ -129,8 +132,19 @@ def test_dist_matrix(self): # Check the whole put together matrix M = R.thickening_distance_matrix() self.assertEqual(M[5][3,10], np.inf) + + def test_set_pos_from_f_preserves_delta_scaled_y_values(self): + # Mapper layout should keep y = delta * f(v), with repulsion only affecting x. + MG = ex_mg.torus(delta=0.2, seed=11) + MG.set_pos_from_f(seed=5, repulsion=0.9) + + self.assertEqual(set(MG.nodes), set(MG.pos_f.keys())) + for v in MG.nodes: + self.assertEqual(MG.pos_f[v][1], MG.delta * MG.f[v]) +if __name__ == '__main__': + unittest.main() if __name__ == '__main__': unittest.main() \ No newline at end of file diff --git a/tests/test_reeb_class.py b/tests/test_reeb_class.py index 0f5cb9c..24c47eb 100644 --- a/tests/test_reeb_class.py +++ b/tests/test_reeb_class.py @@ -1,8 +1,11 @@ import unittest -from cereeberus import ReebGraph + from cereeberus.data import ex_graphs as ex_g from cereeberus.data import ex_reebgraphs as ex_rg +from cereeberus import ReebGraph + + class TestReebClass(unittest.TestCase): def check_reeb(self, R): @@ -227,11 +230,22 @@ def test_matrices(self): B = R.boundary_matrix() self.assertEqual(B.shape, (len(R.nodes), len(R.edges))) + def test_set_pos_from_f_preserves_y_function_values(self): + # The constrained layout updates x-coordinates only; y should remain f(v). + R = ex_rg.torus(multigraph=False) + R.set_pos_from_f(seed=3, repulsion=1.2) + + self.assertEqual(set(R.nodes), set(R.pos_f.keys())) + for v in R.nodes: + self.assertEqual(R.pos_f[v][1], R.f[v]) + +if __name__ == '__main__': + unittest.main() if __name__ == '__main__': unittest.main() \ No newline at end of file