Skip to content

Commit b2a071c

Browse files
authored
Merge pull request #89 from MunchLab/drawing_improvements
Drawing improvements
2 parents 33f0d08 + e1b895c commit b2a071c

File tree

7 files changed

+112
-9
lines changed

7 files changed

+112
-9
lines changed

cereeberus/cereeberus/draw/layout.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ def reeb_x_layout(G, f, seed=None, repulsion=0.5):
105105
Returns:
106106
dict: Mapping node -> x-position.
107107
"""
108+
109+
def _normalize_to_unit_interval(x):
110+
"""Normalize coordinates to [-1, 1] if there is nonzero spread."""
111+
x_range = x.max() - x.min()
112+
if x_range > 1e-9:
113+
return 2 * (x - x.min()) / x_range - 1
114+
return x
115+
108116
nodes = list(G.nodes)
109117
n = len(nodes)
110118
if n == 0:
@@ -135,9 +143,23 @@ def reeb_x_layout(G, f, seed=None, repulsion=0.5):
135143

136144
# Initialise with barycenter ordering, then add tiny jitter to break ties
137145
x0 = _barycenter_init(n, f_vals, edges, level_nodes)
146+
147+
# With no edges, the spring term is absent and repulsion alone can drive
148+
# points apart indefinitely. In this case, return the barycenter layout
149+
# directly (normalised), which is deterministic and finite.
150+
if len(edges) == 0:
151+
x0 = _normalize_to_unit_interval(x0)
152+
return {v: float(x0[idx[v]]) for v in nodes}
153+
138154
rng = np.random.default_rng(seed)
139155
x0 = x0 + rng.standard_normal(n) * 1e-3
140156

157+
# Keep the optimizer in a compact region and add a tiny centering term so
158+
# the objective remains well-conditioned.
159+
bounds = [(-2.0, 2.0)] * n
160+
center_weight = 1e-6
161+
x0 = np.clip(x0, -2.0, 2.0)
162+
141163
def energy(x):
142164
e = 0.0
143165
for i, j in edges:
@@ -146,6 +168,7 @@ def energy(x):
146168
for i, j in same_height_pairs:
147169
diff = x[i] - x[j]
148170
e += repulsion / (diff**2 + 1e-6)
171+
e += center_weight * np.dot(x, x)
149172
return e
150173

151174
def gradient(x):
@@ -161,14 +184,13 @@ def gradient(x):
161184
grad_val = -2 * repulsion * diff / denom
162185
g[i] += grad_val
163186
g[j] -= grad_val
187+
g += 2 * center_weight * x
164188
return g
165189

166-
result = minimize(energy, x0, jac=gradient, method="L-BFGS-B")
190+
result = minimize(energy, x0, jac=gradient, method="L-BFGS-B", bounds=bounds)
167191
x_opt = result.x
168192

169193
# Normalise to [-1, 1]
170-
x_range = x_opt.max() - x_opt.min()
171-
if x_range > 1e-9:
172-
x_opt = 2 * (x_opt - x_opt.min()) / x_range - 1
194+
x_opt = _normalize_to_unit_interval(x_opt)
173195

174196
return {v: float(x_opt[idx[v]]) for v in nodes}

doc_source/images/line.png

6.28 KB
Loading
24 Bytes
Loading

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "cereeberus"
7-
version = "0.1.13"
7+
version = "0.1.14"
88
authors = [
99
{ name="Liz Munch", email="muncheli@msu.edu" },
1010
]

tests/test_draw_layout.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import unittest
2+
3+
import networkx as nx
4+
import numpy as np
5+
from cereeberus.data import ex_reebgraphs as ex_rg
6+
from cereeberus.draw.layout import reeb_x_layout
7+
8+
9+
class TestDrawLayout(unittest.TestCase):
10+
def test_reeb_x_layout_returns_finite_x_for_all_nodes(self):
11+
R = ex_rg.torus(multigraph=False, seed=0)
12+
13+
x_positions = reeb_x_layout(R, R.f, seed=17, repulsion=0.8)
14+
15+
self.assertEqual(set(x_positions.keys()), set(R.nodes))
16+
for v in R.nodes:
17+
self.assertTrue(np.isfinite(x_positions[v]))
18+
self.assertLessEqual(x_positions[v], 1.0 + 1e-9)
19+
self.assertGreaterEqual(x_positions[v], -1.0 - 1e-9)
20+
21+
def test_reeb_x_layout_is_reproducible_with_seed(self):
22+
R = ex_rg.torus(multigraph=False, seed=0)
23+
24+
x_a = reeb_x_layout(R, R.f, seed=123, repulsion=0.8)
25+
x_b = reeb_x_layout(R, R.f, seed=123, repulsion=0.8)
26+
27+
for v in R.nodes:
28+
self.assertAlmostEqual(x_a[v], x_b[v])
29+
30+
def test_reeb_x_layout_empty_graph(self):
31+
G = nx.Graph()
32+
x_positions = reeb_x_layout(G, {}, seed=1, repulsion=0.5)
33+
self.assertEqual(x_positions, {})
34+
35+
def test_reeb_x_layout_same_height_isolated_nodes(self):
36+
# Regression test: no edges + same-height nodes should not trigger
37+
# unbounded optimisation drift.
38+
G = nx.Graph()
39+
nodes = ["u", "v", "w", "z"]
40+
G.add_nodes_from(nodes)
41+
f = {node: 0.0 for node in nodes}
42+
43+
x_positions = reeb_x_layout(G, f, seed=9, repulsion=1.0)
44+
45+
self.assertEqual(set(x_positions.keys()), set(nodes))
46+
for node in nodes:
47+
self.assertTrue(np.isfinite(x_positions[node]))
48+
self.assertLessEqual(x_positions[node], 1.0 + 1e-9)
49+
self.assertGreaterEqual(x_positions[node], -1.0 - 1e-9)
50+
51+
52+
if __name__ == "__main__":
53+
unittest.main()

tests/test_mapper_class.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import unittest
2-
from cereeberus import ReebGraph, MapperGraph
2+
3+
import numpy as np
34
from cereeberus.data import ex_graphs as ex_g
4-
from cereeberus.data import ex_reebgraphs as ex_rg
55
from cereeberus.data import ex_mappergraphs as ex_mg
6-
import numpy as np
6+
from cereeberus.data import ex_reebgraphs as ex_rg
7+
8+
from cereeberus import MapperGraph, ReebGraph
9+
710

811
class TestMapperClass(unittest.TestCase):
912

@@ -129,8 +132,19 @@ def test_dist_matrix(self):
129132
# Check the whole put together matrix
130133
M = R.thickening_distance_matrix()
131134
self.assertEqual(M[5][3,10], np.inf)
135+
136+
def test_set_pos_from_f_preserves_delta_scaled_y_values(self):
137+
# Mapper layout should keep y = delta * f(v), with repulsion only affecting x.
138+
MG = ex_mg.torus(delta=0.2, seed=11)
139+
MG.set_pos_from_f(seed=5, repulsion=0.9)
140+
141+
self.assertEqual(set(MG.nodes), set(MG.pos_f.keys()))
142+
for v in MG.nodes:
143+
self.assertEqual(MG.pos_f[v][1], MG.delta * MG.f[v])
132144

133145

134146

147+
if __name__ == '__main__':
148+
unittest.main()
135149
if __name__ == '__main__':
136150
unittest.main()

tests/test_reeb_class.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import unittest
2-
from cereeberus import ReebGraph
2+
33
from cereeberus.data import ex_graphs as ex_g
44
from cereeberus.data import ex_reebgraphs as ex_rg
55

6+
from cereeberus import ReebGraph
7+
8+
69
class TestReebClass(unittest.TestCase):
710

811
def check_reeb(self, R):
@@ -227,11 +230,22 @@ def test_matrices(self):
227230
B = R.boundary_matrix()
228231
self.assertEqual(B.shape, (len(R.nodes), len(R.edges)))
229232

233+
def test_set_pos_from_f_preserves_y_function_values(self):
234+
# The constrained layout updates x-coordinates only; y should remain f(v).
235+
R = ex_rg.torus(multigraph=False)
236+
R.set_pos_from_f(seed=3, repulsion=1.2)
237+
238+
self.assertEqual(set(R.nodes), set(R.pos_f.keys()))
239+
for v in R.nodes:
240+
self.assertEqual(R.pos_f[v][1], R.f[v])
241+
230242

231243

232244

233245

234246

235247

248+
if __name__ == '__main__':
249+
unittest.main()
236250
if __name__ == '__main__':
237251
unittest.main()

0 commit comments

Comments
 (0)