Skip to main content

scirs2_transform/tda/
alpha_complex.rs

1//! Alpha complex filtration for 2D point clouds
2//!
3//! Implements the Bowyer-Watson incremental Delaunay triangulation and uses
4//! circumradii as filtration values to build the alpha complex. Persistent
5//! homology is then computed via standard boundary-matrix column reduction.
6//!
7//! ## Algorithm
8//!
9//! 1. Build Delaunay triangulation with Bowyer-Watson.
10//! 2. Assign each simplex a filtration value = circumradius of its smallest
11//!    enclosing circle:
12//!    - 0-simplex (vertex):  0.0
13//!    - 1-simplex (edge):    circumradius of the two-point "circle" = half edge length
14//!    - 2-simplex (triangle): circumradius of the circumscribed circle
15//! 3. Sort all simplices by filtration value.
16//! 4. Reduce the boundary matrix to extract persistence pairs.
17
18use crate::error::{Result, TransformError};
19use std::collections::{HashMap, HashSet};
20
21// ─── Configuration ────────────────────────────────────────────────────────────
22
23/// Configuration for alpha complex construction.
24#[derive(Debug, Clone)]
25pub struct AlphaConfig {
26    /// Maximum alpha value — simplices with filtration value > max_alpha are ignored.
27    pub max_alpha: f64,
28}
29
30impl Default for AlphaConfig {
31    fn default() -> Self {
32        Self {
33            max_alpha: f64::INFINITY,
34        }
35    }
36}
37
38// ─── Simplex ──────────────────────────────────────────────────────────────────
39
40/// A simplex with an associated filtration value.
41#[derive(Debug, Clone, PartialEq)]
42pub struct Simplex {
43    /// Sorted vertex indices forming the simplex.
44    pub vertices: Vec<usize>,
45    /// Filtration value (circumradius for alpha complex).
46    pub filtration_value: f64,
47}
48
49impl Simplex {
50    /// Dimension of the simplex (number of vertices minus one).
51    pub fn dimension(&self) -> usize {
52        self.vertices.len().saturating_sub(1)
53    }
54
55    /// Return the boundary faces as Simplices with the same filtration value.
56    ///
57    /// The boundary of a k-simplex consists of all (k-1)-faces obtained by
58    /// removing one vertex at a time.
59    pub fn boundary_faces(&self) -> Vec<Vec<usize>> {
60        if self.vertices.len() <= 1 {
61            return Vec::new();
62        }
63        (0..self.vertices.len())
64            .map(|i| {
65                self.vertices
66                    .iter()
67                    .enumerate()
68                    .filter(|(j, _)| *j != i)
69                    .map(|(_, &v)| v)
70                    .collect::<Vec<usize>>()
71            })
72            .collect()
73    }
74}
75
76// ─── Geometry helpers ─────────────────────────────────────────────────────────
77
78/// Compute the circumradius of the triangle formed by points a, b, c in 2D.
79///
80/// Returns `f64::INFINITY` if the points are collinear (degenerate triangle).
81pub fn circumradius_2d(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> f64 {
82    let ax = a[0] - c[0];
83    let ay = a[1] - c[1];
84    let bx = b[0] - c[0];
85    let by = b[1] - c[1];
86
87    let d = 2.0 * (ax * by - ay * bx);
88    if d.abs() < 1e-14 {
89        return f64::INFINITY; // collinear
90    }
91
92    let ux = (by * (ax * ax + ay * ay) - ay * (bx * bx + by * by)) / d;
93    let uy = (ax * (bx * bx + by * by) - bx * (ax * ax + ay * ay)) / d;
94
95    (ux * ux + uy * uy).sqrt()
96}
97
98/// Euclidean distance between two 2D points.
99#[inline]
100fn dist2d(a: [f64; 2], b: [f64; 2]) -> f64 {
101    let dx = a[0] - b[0];
102    let dy = a[1] - b[1];
103    (dx * dx + dy * dy).sqrt()
104}
105
106// ─── Bowyer-Watson Delaunay triangulation ─────────────────────────────────────
107
108/// A triangle stored by vertex indices (sorted).
109#[derive(Debug, Clone, PartialEq, Eq, Hash)]
110struct Triangle {
111    v: [usize; 3],
112}
113
114impl Triangle {
115    fn new(a: usize, b: usize, c: usize) -> Self {
116        let mut v = [a, b, c];
117        v.sort_unstable();
118        Self { v }
119    }
120}
121
122/// Check whether point p lies inside the circumcircle of triangle (a, b, c).
123fn in_circumcircle(p: [f64; 2], a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> bool {
124    // Use the standard determinant test.
125    let ax = a[0] - p[0];
126    let ay = a[1] - p[1];
127    let bx = b[0] - p[0];
128    let by = b[1] - p[1];
129    let cx = c[0] - p[0];
130    let cy = c[1] - p[1];
131
132    let det = ax * (by * (cx * cx + cy * cy) - cy * (bx * bx + by * by))
133        - ay * (bx * (cx * cx + cy * cy) - cx * (bx * bx + by * by))
134        + (ax * ax + ay * ay) * (bx * cy - by * cx);
135
136    // Positive det means p is inside (assuming CCW orientation of a,b,c).
137    // We check both orientations.
138    let orientation = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
139    if orientation > 0.0 {
140        det > 0.0
141    } else {
142        det < 0.0
143    }
144}
145
146/// Run Bowyer-Watson incremental Delaunay triangulation on a set of 2D points.
147///
148/// Returns a list of triangles (each a sorted triple of vertex indices).
149/// The super-triangle indices (n, n+1, n+2) are stripped from the output.
150fn bowyer_watson(points: &[[f64; 2]]) -> Vec<Triangle> {
151    let n = points.len();
152    if n < 3 {
153        return Vec::new();
154    }
155
156    // Bounding box → super-triangle
157    let (mut min_x, mut min_y) = (f64::INFINITY, f64::INFINITY);
158    let (mut max_x, mut max_y) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
159    for p in points {
160        min_x = min_x.min(p[0]);
161        min_y = min_y.min(p[1]);
162        max_x = max_x.max(p[0]);
163        max_y = max_y.max(p[1]);
164    }
165    let dx = max_x - min_x;
166    let dy = max_y - min_y;
167    let delta_max = dx.max(dy).max(1.0);
168    let mid_x = (min_x + max_x) / 2.0;
169    let mid_y = (min_y + max_y) / 2.0;
170
171    // Super-triangle vertices stored after the real points
172    let st0 = [mid_x - 20.0 * delta_max, mid_y - delta_max];
173    let st1 = [mid_x, mid_y + 20.0 * delta_max];
174    let st2 = [mid_x + 20.0 * delta_max, mid_y - delta_max];
175    let super_indices = [n, n + 1, n + 2];
176
177    // Extended point list (original + super-triangle)
178    let mut pts: Vec<[f64; 2]> = points.to_vec();
179    pts.push(st0);
180    pts.push(st1);
181    pts.push(st2);
182
183    let mut triangles: HashSet<Triangle> = HashSet::from([Triangle::new(
184        super_indices[0],
185        super_indices[1],
186        super_indices[2],
187    )]);
188
189    for (idx, &p) in points.iter().enumerate() {
190        // Find all triangles whose circumcircle contains p
191        let bad: Vec<Triangle> = triangles
192            .iter()
193            .filter(|t| in_circumcircle(p, pts[t.v[0]], pts[t.v[1]], pts[t.v[2]]))
194            .cloned()
195            .collect();
196
197        // Find the boundary polygon of the hole (edges NOT shared by two bad triangles)
198        let mut edge_count: HashMap<(usize, usize), usize> = HashMap::new();
199        for t in &bad {
200            let edges = [(t.v[0], t.v[1]), (t.v[0], t.v[2]), (t.v[1], t.v[2])];
201            for (a, b) in edges {
202                let key = (a.min(b), a.max(b));
203                *edge_count.entry(key).or_insert(0) += 1;
204            }
205        }
206        let boundary: Vec<(usize, usize)> = edge_count
207            .into_iter()
208            .filter(|(_, cnt)| *cnt == 1)
209            .map(|(e, _)| e)
210            .collect();
211
212        // Remove bad triangles
213        for t in &bad {
214            triangles.remove(t);
215        }
216
217        // Re-triangulate with new point
218        for (a, b) in boundary {
219            triangles.insert(Triangle::new(idx, a, b));
220        }
221    }
222
223    // Remove triangles that share vertices with the super-triangle
224    triangles
225        .into_iter()
226        .filter(|t| !t.v.iter().any(|&v| v >= n))
227        .collect()
228}
229
230// ─── AlphaComplex ─────────────────────────────────────────────────────────────
231
232/// Alpha complex built from a 2D point cloud via Delaunay triangulation.
233#[derive(Debug, Clone)]
234pub struct AlphaComplex {
235    /// The input 2D points.
236    pub points: Vec<[f64; 2]>,
237    /// All simplices sorted by filtration value.
238    pub simplices: Vec<Simplex>,
239}
240
241impl AlphaComplex {
242    /// Construct an alpha complex from a set of 2D points.
243    ///
244    /// Uses the Bowyer-Watson algorithm to build the Delaunay triangulation.
245    /// Filtration values are circumradii (half edge-length for 1-simplices,
246    /// circumradius for 2-simplices).
247    pub fn new(points: &[[f64; 2]]) -> Self {
248        let n = points.len();
249        let mut simplex_map: HashMap<Vec<usize>, f64> = HashMap::new();
250
251        // 0-simplices: filtration 0
252        for i in 0..n {
253            simplex_map.insert(vec![i], 0.0);
254        }
255
256        if n >= 2 {
257            let triangles = bowyer_watson(points);
258
259            // Build edge set from Delaunay triangulation
260            let mut edges: HashSet<(usize, usize)> = HashSet::new();
261            for t in &triangles {
262                edges.insert((t.v[0].min(t.v[1]), t.v[0].max(t.v[1])));
263                edges.insert((t.v[0].min(t.v[2]), t.v[0].max(t.v[2])));
264                edges.insert((t.v[1].min(t.v[2]), t.v[1].max(t.v[2])));
265            }
266
267            // 1-simplices: half edge length as filtration value
268            for (a, b) in &edges {
269                let d = dist2d(points[*a], points[*b]) / 2.0;
270                let key = vec![*a, *b];
271                let entry = simplex_map.entry(key).or_insert(f64::INFINITY);
272                if d < *entry {
273                    *entry = d;
274                }
275            }
276
277            // 2-simplices: circumradius of triangle
278            for t in &triangles {
279                let a = t.v[0];
280                let b = t.v[1];
281                let c = t.v[2];
282                let r = circumradius_2d(points[a], points[b], points[c]);
283                // Filtration value = max(circumradius, max edge filtration)
284                let e_ab = simplex_map
285                    .get(&vec![a.min(b), a.max(b)])
286                    .copied()
287                    .unwrap_or(0.0);
288                let e_ac = simplex_map
289                    .get(&vec![a.min(c), a.max(c)])
290                    .copied()
291                    .unwrap_or(0.0);
292                let e_bc = simplex_map
293                    .get(&vec![b.min(c), b.max(c)])
294                    .copied()
295                    .unwrap_or(0.0);
296                let fv = r.max(e_ab).max(e_ac).max(e_bc);
297                simplex_map.insert(vec![a, b, c], fv);
298            }
299        }
300
301        // Convert to Vec<Simplex> and sort
302        let mut simplices: Vec<Simplex> = simplex_map
303            .into_iter()
304            .map(|(vertices, fv)| Simplex {
305                vertices,
306                filtration_value: fv,
307            })
308            .collect();
309
310        // Sort: primary by filtration value, secondary by dimension (ascending), then by vertices
311        simplices.sort_by(|a, b| {
312            a.filtration_value
313                .partial_cmp(&b.filtration_value)
314                .unwrap_or(std::cmp::Ordering::Equal)
315                .then(a.vertices.len().cmp(&b.vertices.len()))
316                .then(a.vertices.cmp(&b.vertices))
317        });
318
319        Self {
320            points: points.to_vec(),
321            simplices,
322        }
323    }
324
325    /// Return all simplices with filtration value ≤ alpha.
326    pub fn filtration(&self, alpha: f64) -> Vec<Simplex> {
327        self.simplices
328            .iter()
329            .filter(|s| s.filtration_value <= alpha)
330            .cloned()
331            .collect()
332    }
333
334    /// Compute persistence pairs (birth, death, dimension) via boundary matrix reduction.
335    ///
336    /// Returns pairs sorted by birth value.
337    pub fn persistence_pairs(&self) -> Vec<(f64, f64, usize)> {
338        compute_persistence_from_simplices(&self.simplices)
339    }
340}
341
342// ─── Boundary matrix reduction ────────────────────────────────────────────────
343
344/// Compute persistence pairs from a filtered simplicial complex.
345///
346/// Uses the standard reduction algorithm (Edelsbrunner et al. 2002):
347/// column j is reduced by subtracting the column whose pivot equals pivot(j).
348///
349/// Returns `(birth, death, dimension)` triples.
350pub fn compute_persistence_from_simplices(simplices: &[Simplex]) -> Vec<(f64, f64, usize)> {
351    let n = simplices.len();
352
353    // Map each simplex to its index in the filtration
354    let mut simplex_index: HashMap<Vec<usize>, usize> = HashMap::new();
355    for (idx, s) in simplices.iter().enumerate() {
356        simplex_index.insert(s.vertices.clone(), idx);
357    }
358
359    // Build boundary matrix as columns of sorted row indices (mod-2 representation)
360    let mut columns: Vec<Vec<usize>> = simplices
361        .iter()
362        .map(|s| {
363            let mut col: Vec<usize> = s
364                .boundary_faces()
365                .iter()
366                .filter_map(|face| simplex_index.get(face).copied())
367                .collect();
368            col.sort_unstable();
369            col
370        })
371        .collect();
372
373    // Standard column reduction
374    let mut pivot_col: HashMap<usize, usize> = HashMap::new(); // pivot row -> column index
375    let mut pairs: Vec<(f64, f64, usize)> = Vec::new();
376    let mut paired: HashSet<usize> = HashSet::new();
377
378    for j in 0..n {
379        // Reduce column j
380        while let Some(&pivot) = columns[j].last() {
381            if let Some(&k) = pivot_col.get(&pivot) {
382                // XOR columns (mod-2 addition)
383                let col_k = columns[k].clone();
384                sym_diff_sorted(&mut columns[j], &col_k);
385            } else {
386                break;
387            }
388        }
389
390        if let Some(&pivot) = columns[j].last() {
391            // Column j has pivot at row `pivot`
392            pivot_col.insert(pivot, j);
393            let birth_idx = pivot;
394            let death_idx = j;
395            let dim = simplices[birth_idx].dimension();
396            pairs.push((
397                simplices[birth_idx].filtration_value,
398                simplices[death_idx].filtration_value,
399                dim,
400            ));
401            paired.insert(birth_idx);
402            paired.insert(death_idx);
403        }
404    }
405
406    // Essential features (unpaired simplices) get death = infinity
407    // We skip them as the task requests only finite pairs from the matrix reduction
408
409    pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
410    pairs
411}
412
413/// Symmetric difference of two sorted vectors (mod-2 addition of columns).
414pub fn sym_diff_sorted(a: &mut Vec<usize>, b: &[usize]) {
415    let mut result: Vec<usize> = Vec::new();
416    let mut ai = 0;
417    let mut bi = 0;
418    while ai < a.len() && bi < b.len() {
419        match a[ai].cmp(&b[bi]) {
420            std::cmp::Ordering::Less => {
421                result.push(a[ai]);
422                ai += 1;
423            }
424            std::cmp::Ordering::Greater => {
425                result.push(b[bi]);
426                bi += 1;
427            }
428            std::cmp::Ordering::Equal => {
429                // Same element: cancel in mod-2
430                ai += 1;
431                bi += 1;
432            }
433        }
434    }
435    result.extend_from_slice(&a[ai..]);
436    result.extend_from_slice(&b[bi..]);
437    *a = result;
438}
439
440// ─── Tests ────────────────────────────────────────────────────────────────────
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    /// Equilateral triangle with side length 1.0.
447    fn equilateral_pts() -> Vec<[f64; 2]> {
448        vec![[0.0, 0.0], [1.0, 0.0], [0.5, 3_f64.sqrt() / 2.0]]
449    }
450
451    #[test]
452    fn test_circumradius_equilateral() {
453        // Equilateral triangle with side 1: circumradius = 1/sqrt(3)
454        let pts = equilateral_pts();
455        let r = circumradius_2d(pts[0], pts[1], pts[2]);
456        let expected = 1.0 / 3_f64.sqrt();
457        assert!(
458            (r - expected).abs() < 1e-10,
459            "circumradius={r}, expected={expected}"
460        );
461    }
462
463    #[test]
464    fn test_circumradius_right_triangle() {
465        // Right triangle with legs 3,4 — hypotenuse 5 — circumradius = hypotenuse/2 = 2.5
466        let a = [0.0, 0.0];
467        let b = [3.0, 0.0];
468        let c = [0.0, 4.0];
469        let r = circumradius_2d(a, b, c);
470        assert!((r - 2.5).abs() < 1e-10, "circumradius={r}");
471    }
472
473    #[test]
474    fn test_filtration_threshold() {
475        // 5 points: a square + center
476        let pts: Vec<[f64; 2]> = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.5, 0.5]];
477        let ac = AlphaComplex::new(&pts);
478
479        // At alpha=0 only vertices should be present
480        let f0 = ac.filtration(0.0);
481        assert!(f0.iter().all(|s| s.dimension() == 0));
482        assert_eq!(f0.len(), 5);
483
484        // At alpha=infinity all simplices returned
485        let f_inf = ac.filtration(f64::INFINITY);
486        assert!(!f_inf.is_empty());
487        // All 5 vertices present
488        assert!(f_inf.iter().filter(|s| s.dimension() == 0).count() >= 5);
489    }
490
491    #[test]
492    fn test_persistence_pairs_triangle() {
493        let pts = equilateral_pts();
494        let ac = AlphaComplex::new(&pts);
495        let pairs = ac.persistence_pairs();
496        // For a filled triangle, we expect some pairs (the 2-simplex kills a 1-cycle)
497        // Birth should be <= death for all pairs
498        for (birth, death, _dim) in &pairs {
499            assert!(birth <= death, "Invalid pair: birth={birth}, death={death}");
500        }
501    }
502
503    #[test]
504    fn test_persistence_pairs_not_empty_for_triangle() {
505        let pts = equilateral_pts();
506        let ac = AlphaComplex::new(&pts);
507        let pairs = ac.persistence_pairs();
508        // There must be at least one pair (connecting the 3 vertices)
509        assert!(!pairs.is_empty(), "Expected at least one persistence pair");
510    }
511
512    #[test]
513    fn test_five_point_cloud() {
514        let pts: Vec<[f64; 2]> = vec![[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0], [1.0, 1.0]];
515        let ac = AlphaComplex::new(&pts);
516        // Should have 5 vertices
517        assert_eq!(
518            ac.simplices.iter().filter(|s| s.dimension() == 0).count(),
519            5
520        );
521        // Edges exist
522        assert!(
523            ac.simplices.iter().any(|s| s.dimension() == 1),
524            "Expected edges"
525        );
526    }
527}