Skip to content

Commit bd726e8

Browse files
committed
Fixing the degenerate edge problem
1 parent dcf4c3a commit bd726e8

File tree

4 files changed

+226
-85
lines changed

4 files changed

+226
-85
lines changed
Lines changed: 119 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
from .unionfind import UnionFind
2-
from ..reeb.lowerstar import LowerStar
1+
from itertools import groupby as _groupby
2+
33
import numpy as np
44

5+
from ..reeb.lowerstar import LowerStar
6+
from .unionfind import UnionFind
7+
58

69
def is_face(sigma, tau):
710
"""
@@ -72,120 +75,164 @@ def computeReeb(K: LowerStar, verbose=False):
7275

7376
funcVals = [(i, K.filtration([i])) for i in K.iter_vertices()]
7477
funcVals.sort(key=lambda x: x[1]) # Sort by filtration value
78+
# Group vertices that share the same filtration value into batches.
79+
# A horizontal edge (both endpoints at the same height) must be processed
80+
# within one batch so it properly merges its endpoints into a single Reeb node.
81+
grouped = [
82+
(filt, list(grp))
83+
for filt, grp in _groupby(funcVals, key=lambda x: x[1])
84+
]
7585

7686
R = ReebGraph()
77-
7887
currentLevelSet = []
79-
components = {}
8088
half_edge_index = 0
81-
82-
# This will keep track of the components represented by every vertex in the graph so far.
83-
# It will be vertName: connected_component (given as a list of lists) represented by that vertex
8489
vert_to_component = {}
85-
8690
edges_at_prev_level = []
8791

88-
for i, (vert, filt) in enumerate(funcVals):
89-
if verbose:
90-
print(f"\n---\n Processing {vert} at func val {filt:.2f}")
92+
def _dedup(lst):
93+
seen = set()
94+
out = []
95+
for s in lst:
96+
key = tuple(sorted(s))
97+
if key not in seen:
98+
seen.add(key)
99+
out.append(s)
100+
return out
101+
102+
for group_idx, (filt, group_verts) in enumerate(grouped):
91103
now_min = filt
92-
now_max = funcVals[i + 1][1] if i + 1 < len(funcVals) else np.inf
93-
star = K.get_star([vert])
94-
lower_star = [s[0] for s in star if s[1] <= filt and len(s[0]) > 1]
95-
upper_star = [s[0] for s in star if s[1] > filt and len(s[0]) > 1]
104+
now_max = (
105+
grouped[group_idx + 1][0] if group_idx + 1 < len(grouped) else np.inf
106+
)
107+
vert_names = [v for v, _ in group_verts]
96108

97109
if verbose:
98-
print(f" Lower star simplices: {lower_star}")
99-
print(f" Upper star simplices: {upper_star}")
110+
print(f"\n---\n Processing group at func val {filt:.2f}: {vert_names}")
111+
112+
# Classify all simplex stars for this batch into three groups:
113+
#
114+
# lower_nonhoriz: s_filt == filt AND at least one vertex strictly below filt.
115+
# These were added to currentLevelSet by an earlier vertex; remove them now.
116+
#
117+
# horizontal: s_filt == filt AND ALL vertices are at filt.
118+
# These connect same-height vertices in the same batch.
119+
# Add them temporarily so they link the endpoints into one component.
120+
#
121+
# upper: s_filt > filt.
122+
# Add persistently; they carry the level set upward to the next critical point.
123+
#
124+
# Note: because filt(simplex) = max(vertex filtrations) >= filt for any simplex
125+
# in the star of a vertex at height filt, s_filt < filt is impossible here.
126+
all_lower_nonhoriz = []
127+
all_upper = []
128+
all_horizontal = []
129+
130+
for vert in vert_names:
131+
for s in K.get_star([vert]):
132+
simplex, s_filt = s[0], s[1]
133+
if len(simplex) <= 1:
134+
continue
135+
if s_filt > filt:
136+
all_upper.append(simplex)
137+
elif all(K.filtration([u]) == filt for u in simplex):
138+
all_horizontal.append(simplex)
139+
else:
140+
all_lower_nonhoriz.append(simplex)
141+
142+
all_lower_nonhoriz = _dedup(all_lower_nonhoriz)
143+
all_upper = _dedup(all_upper)
144+
all_horizontal = _dedup(all_horizontal)
100145

101-
# ----
102-
# Update the levelset list
103-
# ----
146+
if verbose:
147+
print(f" Lower (non-horiz) simplices: {all_lower_nonhoriz}")
148+
print(f" Horizontal simplices: {all_horizontal}")
149+
print(f" Upper simplices: {all_upper}")
104150

105-
for s in lower_star:
106-
# Remove from current level set
151+
# Step 1: Remove the lower non-horizontal simplices from the active level set.
152+
for s in all_lower_nonhoriz:
107153
if s in currentLevelSet:
108154
currentLevelSet.remove(s)
109155

110-
currentLevelSet.append([vert]) # Add the vertex itself to the level set
111-
components_at_vertex = get_levelset_components(currentLevelSet)
156+
# Step 2: Add this batch's vertices and its horizontal simplices to the level set.
157+
for vert in vert_names:
158+
currentLevelSet.append([vert])
159+
for s in all_horizontal:
160+
if s not in currentLevelSet:
161+
currentLevelSet.append(s)
162+
163+
if verbose:
164+
print(f" Current level set: {currentLevelSet}")
165+
166+
# Step 3: Compute connected components; create one Reeb node per component.
167+
components_at_level = get_levelset_components(currentLevelSet)
112168

113169
if verbose:
114-
print(f" Current level set simplices: {currentLevelSet}")
115-
print(f" Level set components at vertex {vert} (func val {filt:.2f}):")
116-
for comp in components_at_vertex.values():
117-
print(f" Component: {comp}")
170+
print(f" Level set components:")
171+
for comp in components_at_level.values():
172+
print(f" {comp}")
118173

119174
verts_at_level = []
120-
for rep, comp in components_at_vertex.items():
121-
# Add a vertex for each component in this levelset
175+
for rep, comp in components_at_level.items():
122176
nextNodeName = R.get_next_vert_name()
123177
R.add_node(nextNodeName, now_min)
124-
vert_to_component[nextNodeName] = (
125-
comp # Store the component represented by this vertex
126-
)
178+
vert_to_component[nextNodeName] = comp
127179
verts_at_level.append(nextNodeName)
128180

129-
# Check if any simplex in vertex component is a subset of any of simplices in a previous edge's component
181+
# Connect incoming half-edge sentinels whose component contains a face
182+
# of a simplex in this component.
130183
for e in edges_at_prev_level:
131184
prev_comp = vert_to_component[e]
132185
if any(
133-
[
134-
is_face(prev_simp, simp)
135-
for simp in comp
136-
for prev_simp in prev_comp
137-
]
186+
is_face(prev_simp, simp)
187+
for simp in comp
188+
for prev_simp in prev_comp
138189
):
139190
R.add_edge(e, nextNodeName)
140191

141-
# ----
142-
# Add the edge vertices for after the vertex is passed
143-
# ----
144-
145-
# Remove the vertex from the level set
146-
if [vert] in currentLevelSet:
147-
currentLevelSet.remove([vert])
192+
# Step 4: Remove vertices and horizontal simplices – they live only at this exact height.
193+
for vert in vert_names:
194+
if [vert] in currentLevelSet:
195+
currentLevelSet.remove([vert])
196+
for s in all_horizontal:
197+
if s in currentLevelSet:
198+
currentLevelSet.remove(s)
148199

149-
# Add the upper star to the current level set
150-
for s in upper_star:
200+
# Step 5: Add upper-star simplices to carry the level set forward.
201+
for s in all_upper:
151202
if s not in currentLevelSet:
152203
currentLevelSet.append(s)
153204

154-
components = get_levelset_components(currentLevelSet)
155205
if verbose:
156-
print(f"\n Updated current level set simplices: {currentLevelSet}")
157-
print(f" Level set components after vertex {vert} (func val {filt:.2f}):")
158-
for comp in components.values():
159-
print(f" Component: {comp}")
160-
# ----
161-
# Set up a vertex in the Reeb graph for each connected component
162-
# These will represent edges
163-
# These are at height (now_min + now_max)/2
164-
# ----
206+
print(f"\n Updated level set: {currentLevelSet}")
207+
208+
# Step 6: Compute components above this level; create half-edge sentinel nodes
209+
# at height (now_min + now_max) / 2 and connect them downward to the Reeb nodes
210+
# created in Step 3.
211+
components_above = get_levelset_components(currentLevelSet)
212+
213+
if verbose:
214+
print(f" Level set components above:")
215+
for comp in components_above.values():
216+
print(f" {comp}")
217+
165218
edges_at_prev_level = []
166-
for comp in components.values():
167-
# Create a new vertex in the Reeb graph
219+
for comp in components_above.values():
168220
e_name = "e_" + str(half_edge_index)
169221
R.add_node(e_name, (now_min + now_max) / 2)
170-
vert_to_component[e_name] = (
171-
comp # Store the component represented by this half edge top
172-
)
222+
vert_to_component[e_name] = comp
173223
half_edge_index += 1
174224
edges_at_prev_level.append(e_name)
175225

176-
# Now connect to the vertices at this level
226+
# Connect downward to Reeb nodes at this level.
177227
for v in verts_at_level:
178-
179-
# Get the component represented by vertex v
180228
prev_comp = vert_to_component[v]
181-
182229
if any(
183-
[
184-
is_face(simp, prev_simp)
185-
for simp in comp
186-
for prev_simp in prev_comp
187-
]
230+
is_face(simp, prev_simp)
231+
for simp in comp
232+
for prev_simp in prev_comp
188233
):
189234
R.add_edge(v, e_name)
190235

191236
return R
237+
238+
return R

doc_source/notebooks/compute_reeb.ipynb

Lines changed: 74 additions & 12 deletions
Large diffs are not rendered by default.

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.11"
7+
version = "0.1.12"
88
authors = [
99
{ name="Liz Munch", email="muncheli@msu.edu" },
1010
]

tests/test_lowerstar_class.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,38 @@ def test_computeReeb(self):
5757
self.assertGreater(len(R.nodes), 0)
5858
self.assertGreater(len(R.edges), 0)
5959

60+
def test_computeReeb_horizontal_edge(self):
61+
# Regression test: when two vertices share the same filtration value and
62+
# are connected by an edge (a "horizontal" edge), the level-set connected
63+
# component is that entire edge, so the Reeb graph must collapse them to
64+
# a single node. Previously this raised ValueError.
65+
#
66+
# Complex: triangle with vertices 0,1,2 and edges [0,1],[0,2],[1,2].
67+
# Filtration (x-coordinate): f(0)=0, f(1)=1, f(2)=0.
68+
# Edge [0,2] is horizontal. The Reeb graph should have exactly 1 node at
69+
# height 0 (not 2 separate nodes) and 2 edges leading up to vertex 1.
70+
K = LowerStar()
71+
K.insert([0, 1])
72+
K.insert([0, 2])
73+
K.insert([1, 2])
74+
75+
K.assign_filtration(0, 0.0)
76+
K.assign_filtration(1, 1.0)
77+
K.assign_filtration(2, 0.0)
78+
79+
R = computeReeb(K)
80+
81+
self.assertIsInstance(R, ReebGraph)
82+
83+
# Exactly one node at height 0 (the collapsed horizontal component)
84+
nodes_at_zero = [v for v in R.nodes if R.f[v] == 0.0]
85+
self.assertEqual(len(nodes_at_zero), 1,
86+
"Horizontal edge should collapse to a single Reeb node")
87+
88+
# The graph should be connected
89+
import networkx as nx
90+
self.assertTrue(nx.is_weakly_connected(R))
91+
6092
def test_torus_example_class(self):
6193
# Test the torus example from the documentation.
6294
T = Torus()

0 commit comments

Comments
 (0)