Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions cereeberus/cereeberus/draw/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Comment on lines 157 to +171
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module docstring at the top defines the layout energy without the new centering term, but the implementation now adds center_weight * dot(x, x) (and bounds). Please update the documented energy expression/description so it matches the optimizer objective.

Copilot uses AI. Check for mistakes.
return e

def gradient(x):
Expand All @@ -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}
Binary file modified doc_source/images/line.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified doc_source/images/torus-extraverts.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]
Expand Down
53 changes: 53 additions & 0 deletions tests/test_draw_layout.py
Original file line number Diff line number Diff line change
@@ -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()
20 changes: 17 additions & 3 deletions tests/test_mapper_class.py
Original file line number Diff line number Diff line change
@@ -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):

Expand Down Expand Up @@ -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__':
Comment on lines +148 to 149
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two identical if __name__ == '__main__': unittest.main() blocks at the end of the file, which will run the suite twice when executed directly. Remove the duplicate so there is only a single entry point.

Suggested change
unittest.main()
if __name__ == '__main__':

Copilot uses AI. Check for mistakes.
unittest.main()
16 changes: 15 additions & 1 deletion tests/test_reeb_class.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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__':
Comment on lines +249 to 250
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two identical if __name__ == '__main__': unittest.main() blocks at the end of the file, which will run the suite twice when executed directly. Remove the duplicate so there is only a single entry point.

Suggested change
unittest.main()
if __name__ == '__main__':

Copilot uses AI. Check for mistakes.
unittest.main()
Loading