fast_nnt/algorithms/
equal_angle.rs

1use anyhow::{Result, anyhow, bail};
2use fixedbitset::FixedBitSet;
3use petgraph::{
4    csr::IndexType,
5    prelude::{EdgeIndex, NodeIndex},
6    visit::{EdgeRef, NodeIndexable},
7};
8use std::collections::{HashMap, HashSet};
9
10use crate::{
11    algorithms::convex_hull::convex_hull_apply,
12    data::splits_blocks::{SplitsBlock, SplitsProvider},
13    phylo::phylo_splits_graph::PhyloSplitsGraph,
14    splits::asplit::ASplitView,
15};
16use log::info;
17
18#[derive(Copy, Clone, Debug)]
19pub struct Pt(pub f64, pub f64);
20
21/// Options for EqualAngle
22#[derive(Copy, Clone, Debug)]
23pub struct EqualAngleOpts {
24    /// Use edge weights when laying out coordinates (true = weighted steps)
25    pub use_weights: bool,
26    /// Total circle angle for split directions (default 360.0)
27    pub total_angle_deg: f64,
28    /// Root split id for tiny offsets when !use_weights (0/neg = none)
29    pub root_split: i32,
30}
31
32impl Default for EqualAngleOpts {
33    fn default() -> Self {
34        Self {
35            use_weights: true,
36            total_angle_deg: 360.0,
37            root_split: 0,
38        }
39    }
40}
41
42/// Compute a split network with the (ported) Equal-Angle workflow.
43/// - `taxa_labels` is your “TaxaBlock” (1-based semantic: label for taxon `t` is `taxa_labels[t-1]`)
44/// - `splits` is the circular `SplitsBlock` with a cycle
45/// - `graph` is mutated in-place
46/// - `forbidden_splits` skips setting angles for these split ids (1-based); pass `None` to set all
47/// - `used_splits` is cleared and then bits set for each non-trivial circular split we consume
48///
49/// Returns `Ok(all_used)` where `all_used == true` iff every non-trivial split is circular
50pub fn equal_angle_apply(
51    opts: EqualAngleOpts,
52    taxa_labels: &[String],
53    splits: &SplitsBlock,
54    graph: &mut PhyloSplitsGraph,
55    forbidden_splits: Option<&FixedBitSet>,
56    used_splits: &mut FixedBitSet,
57) -> Result<bool> {
58    let t0 = std::time::Instant::now();
59    graph.clear();
60    used_splits.clear();
61
62    let ntax = taxa_labels.len();
63    if ntax == 0 {
64        info!("EqualAngle: no taxa; nothing to do");
65        return Ok(true);
66    }
67
68    let cycle = {
69        let c = splits
70            .cycle()
71            .ok_or_else(|| anyhow::anyhow!("SplitsBlock has no cycle"))?;
72        normalize_cycle_1based(c)?
73    };
74
75    // 1) init star graph with center and trivial split edges
76    init_graph_star(taxa_labels, splits, &cycle, graph)?;
77
78    // 2) non-trivial splits ordered by |partContaining(1)|
79    let ordered = get_nontrivial_splits_ordered(splits);
80
81    // 3) wrap each circular split (TODO: full wrapping)
82    let mut all_used = true;
83    for &sid in &ordered {
84        if is_circular_by_cycle(splits, sid, &cycle) {
85            // Wrap split sid around the cycle
86            wrap_split(ntax, splits, sid, &cycle, graph)?;
87            // Placeholder: log and mark used. We don’t modify graph topology here yet.
88            used_splits.grow(splits.nsplits() + 1);
89            used_splits.insert(sid);
90        } else {
91            all_used = false;
92        }
93    }
94
95    // 4) remove temporary trivial edges (none if we had trivial splits, some if we didn’t)
96    remove_temporary_trivial_edges(graph);
97
98    if !all_used {
99        // assign remaining with ConvexHull
100        convex_hull_apply(ntax, taxa_labels, splits.get_splits(), graph)?;
101    }
102    // 5) assign angles to edges from split directions
103    assign_angles_to_edges(
104        ntax,
105        splits,
106        &cycle,
107        graph,
108        forbidden_splits,
109        opts.total_angle_deg,
110    );
111
112    // 6) rotate so the edge leaving taxon 1 points to 9 o’clock
113    let (_align, _extra) = rotate_angles_align_then_offset(graph, 1, 180.0, 0.0);
114
115    // 7) assign coordinates from angles (DFS through graph)
116    let _coords = assign_coordinates_to_nodes(opts.use_weights, graph, 1, opts.root_split);
117
118    // 8) add leaf node labels from taxa
119    add_leaf_labels_from_taxa(graph, taxa_labels);
120
121    info!(
122        "EqualAngle: initialized network with {} nodes, {} edges in {:?} (use_weights={})",
123        graph.base.graph.node_count(),
124        graph.base.graph.edge_count(),
125        t0.elapsed(),
126        opts.use_weights
127    );
128
129    Ok(all_used)
130}
131
132/* ----------------------- Step helpers, mostly 1:1 ports ----------------------- */
133
134/// normalize cycle so that cycle[1] == 1; keep 1-based convention with index 0 unused
135pub fn normalize_cycle_1based(cycle_1based: &[usize]) -> Result<Vec<usize>> {
136    if cycle_1based.is_empty() || cycle_1based[0] != 0 {
137        bail!("cycle must be 1-based with index 0 unused");
138    }
139    let n = cycle_1based.len() - 1;
140    let mut k = 1usize;
141    while k <= n && cycle_1based[k] != 1 {
142        k += 1;
143    }
144    if k > n {
145        bail!("cycle does not contain taxon 1");
146    }
147
148    let mut out = vec![0usize; n + 1];
149    let mut i = 1;
150    let mut j = k;
151    while j <= n {
152        out[i] = cycle_1based[j];
153        i += 1;
154        j += 1;
155    }
156    j = 1;
157    while i <= n {
158        out[i] = cycle_1based[j];
159        i += 1;
160        j += 1;
161    }
162    Ok(out)
163}
164
165/// Build the initial star: center node connected to each taxon leaf in cycle order.
166/// Set edge split to trivial split id if present; else split=-1 (temporary).
167fn init_graph_star(
168    taxa: &[String],
169    splits: &SplitsBlock,
170    cycle: &[usize], // 1-based
171    g: &mut PhyloSplitsGraph,
172) -> Result<()> {
173    g.clear();
174
175    let ntax = taxa.len();
176    let mut taxon2trivial: Vec<i32> = vec![0; ntax + 1];
177    for s in 1..=splits.nsplits() {
178        let sp = splits.split(s);
179        if sp.size() == 1 {
180            if let Some(t) = first_member(sp.smaller_part()) {
181                taxon2trivial[t] = s as i32;
182            }
183        }
184    }
185
186    let center = g.base.new_node();
187    // remember leaf node per taxon
188    for i in 1..=ntax {
189        let t = cycle[i];
190        let leaf = g.base.new_node();
191        g.base.add_taxon(leaf, t);
192        let e = g.new_edge(center, leaf)?;
193        let sid = taxon2trivial[t];
194        if sid != 0 {
195            let w = splits.split(sid as usize).weight();
196            g.base.set_weight(e, w);
197            g.set_split(e, sid);
198        } else {
199            g.set_split(e, -1); // temporary trivial edge
200        }
201    }
202    Ok(())
203}
204
205/// Gather non-trivial split ids sorted by |partContaining(1)|
206fn get_nontrivial_splits_ordered(splits: &SplitsBlock) -> Vec<usize> {
207    let mut pairs: Vec<(usize, usize)> = Vec::new(); // (size, sid)
208    for s in 1..=splits.nsplits() {
209        let sp = splits.split(s);
210        if sp.size() > 1 {
211            let size = sp.part_containing(1).count_ones(..);
212            pairs.push((size, s));
213        }
214    }
215    pairs.sort_by_key(|p| p.0);
216    pairs.into_iter().map(|p| p.1).collect()
217}
218
219/// Simple circularity check: split part not containing cycle[1] must be a contiguous arc in cycle
220fn is_circular_by_cycle(splits: &SplitsBlock, sid: usize, cycle: &[usize]) -> bool {
221    let sp = splits.split(sid);
222    let ntax = cycle.len() - 1;
223    let part = sp.part_not_containing(cycle[1]);
224
225    // collect cycle positions (2..=n) for taxa in part
226    let mut pos: Vec<usize> = Vec::new();
227    for i in 2..=ntax {
228        let t = cycle[i];
229        if part.contains(t) {
230            pos.push(i);
231        }
232    }
233    if pos.is_empty() {
234        return true;
235    } // degenerate
236    // must be contiguous (allow wrap-around handled by cycle normalization)
237    for w in pos.windows(2) {
238        if w[1] != w[0] + 1 {
239            return false;
240        }
241    }
242    true
243}
244
245/// Remove edges with split == -1 by merging leaf node into its neighbor (move taxa labels)
246fn remove_temporary_trivial_edges(g: &mut PhyloSplitsGraph) {
247    let mut to_remove: Vec<EdgeIndex> = Vec::new();
248    for e in g.base.graph.edge_indices() {
249        if g.get_split(e) == -1 {
250            to_remove.push(e);
251        }
252    }
253    for e in to_remove {
254        if let Some((u, v)) = g.base.graph.edge_endpoints(e) {
255            // choose leaf node (degree 1) to delete; move its taxa to the other node
256            let du = g.base.graph.neighbors(u).count();
257            let dv = g.base.graph.neighbors(v).count();
258            let (leaf, keep) = if du == 1 {
259                (u, v)
260            } else if dv == 1 {
261                (v, u)
262            } else {
263                continue;
264            };
265
266            if let Some(list) = g
267                .base
268                .node2taxa()
269                .expect("No taxa mapping")
270                .get(&leaf)
271                .cloned()
272            {
273                for t in list {
274                    g.base.add_taxon(keep, t);
275                }
276                // clear on leaf
277                g.base.clear_taxa_for_node(leaf);
278            }
279            g.base.graph.remove_edge(e);
280            // remove leaf node completely
281            if g.base.graph.neighbors(leaf).count() == 0 {
282                g.base.graph.remove_node(leaf);
283            }
284        }
285    }
286}
287
288/// Assign angles to edges: compute split directions (per split id) on a circle,
289/// then stamp on edges unless forbidden.
290pub fn assign_angles_to_edges(
291    ntaxa: usize,
292    splits: &SplitsBlock,
293    cycle: &[usize],
294    g: &mut PhyloSplitsGraph,
295    forbidden: Option<&fixedbitset::FixedBitSet>,
296    total_angle: f64,
297) {
298    let split2angle = assign_angles_to_splits(ntaxa, splits, cycle, total_angle);
299
300    // Collect edge ids first to avoid holding an immutable borrow during mutation.
301    let edges: Vec<petgraph::prelude::EdgeIndex> = g.base.graph.edge_indices().collect();
302
303    for e in edges {
304        let sid = g.get_split(e);
305        if sid > 0 {
306            let sidu = sid as usize;
307            let is_forbidden = forbidden.map_or(false, |bs| sidu < bs.len() && bs.contains(sidu));
308            if !is_forbidden {
309                if let Some(&theta) = split2angle.get(sidu) {
310                    g.set_angle(e, theta);
311                }
312            }
313        }
314    }
315}
316
317/// Compute split direction for each split id (1-based) by “mid-angle” between the first/last
318/// taxa (in cycle order) that lie on the part not containing cycle[1].
319pub fn assign_angles_to_splits(
320    ntaxa: usize,
321    splits: &SplitsBlock,
322    cycle: &[usize],
323    total_angle: f64,
324) -> Vec<f64> {
325    // angles for taxa positions (1..=ntax), centered so taxon 1 points at 270 - total/2
326    let mut taxa_angles = vec![0.0f64; ntaxa + 1];
327    for tpos in 1..=ntaxa {
328        taxa_angles[tpos] =
329            total_angle * ((tpos - 1) as f64) / (ntaxa as f64) + (270.0 - 0.5 * total_angle);
330    }
331
332    // split angles (1-based)
333    let mut split2angle = vec![0.0f64; splits.nsplits() + 1];
334
335    for s in 1..=splits.nsplits() {
336        let part = splits.split(s).part_not_containing(cycle[1]);
337        let mut xp = 0usize;
338        let mut xq = 0usize;
339        for i in 2..=ntaxa {
340            let t = cycle[i];
341            if part.contains(t) {
342                if xp == 0 {
343                    xp = i;
344                }
345                xq = i;
346            }
347        }
348        if xp == 0 {
349            // degenerate; aim at first taxon
350            split2angle[s] = modulo360(taxa_angles[1]);
351        } else {
352            split2angle[s] = modulo360(0.5 * (taxa_angles[xp] + taxa_angles[xq]));
353        }
354    }
355    split2angle
356}
357
358/// Align the first edge incident to `taxon_id` to `target_deg` **then**
359/// apply a global rotation of `extra_offset_deg`.
360///
361/// Returns `(align_delta, extra_offset_deg)` where:
362/// - `align_delta` is `Some(deg)` if alignment was performed, else `None`
363///   (e.g., if the taxon or leaf edge wasn’t found).
364pub fn rotate_angles_align_then_offset(
365    g: &mut PhyloSplitsGraph,
366    taxon_id: usize,
367    target_deg: f64,
368    extra_offset_deg: f64,
369) -> (Option<f64>, f64) {
370    // Try to align this leaf’s incident edge
371    let align_delta = rotate_angles_align_leaf(g, taxon_id, target_deg);
372
373    // Always apply the extra global rotation afterward
374    if extra_offset_deg != 0.0 {
375        rotate_angles_in_place(g, extra_offset_deg);
376    }
377    (align_delta, extra_offset_deg)
378}
379
380/// Add `delta_deg` (in degrees) to every stored edge angle, modulo 360.
381pub fn rotate_angles_in_place(g: &mut PhyloSplitsGraph, delta_deg: f64) {
382    let edges: Vec<petgraph::prelude::EdgeIndex> = g.base.graph.edge_indices().collect();
383
384    for e in edges {
385        let a = g.get_angle(e);
386        g.set_angle(e, modulo360(a + delta_deg));
387    }
388}
389
390/// Rotate all edge angles so the *first* edge incident to `taxon_id` is aligned to `target_deg`.
391/// Returns the delta that was applied (in degrees), or `None` if that leaf/edge couldn't be found.
392pub fn rotate_angles_align_leaf(
393    g: &mut PhyloSplitsGraph,
394    taxon_id: usize,
395    target_deg: f64,
396) -> Option<f64> {
397    let v = g
398        .base
399        .taxon2node()
400        .expect("No taxon2node map")
401        .get(&taxon_id)?;
402    let e = g.base.graph.edges(*v).next()?.id(); // take first incident edge
403    let cur = g.get_angle(e);
404    let delta = modulo360(target_deg - cur);
405    rotate_angles_in_place(g, delta);
406    Some(delta)
407}
408
409pub fn assign_coordinates_to_nodes(
410    use_weights: bool,
411    g: &PhyloSplitsGraph,
412    start_taxon_id: usize,
413    root_split: i32,
414) -> HashMap<NodeIndex, Pt> {
415    let mut pts = HashMap::new();
416    let Some(&v0) = g.base.taxon2node().expect("taxon map").get(&start_taxon_id) else {
417        return pts;
418    };
419    pts.insert(v0, Pt(0.0, 0.0));
420
421    let mut visited = FixedBitSet::with_capacity(g.base.graph.node_bound().index());
422    let mut splits_in_path = FixedBitSet::with_capacity((g.max_split_id().max(0) as usize) + 2);
423
424    dfs_coords(
425        use_weights,
426        g,
427        v0,
428        &mut visited,
429        &mut splits_in_path,
430        &mut pts,
431        root_split,
432    );
433    pts
434}
435
436fn dfs_coords(
437    use_weights: bool,
438    g: &PhyloSplitsGraph,
439    v: NodeIndex,
440    visited: &mut FixedBitSet,
441    splits_in_path: &mut FixedBitSet,
442    pts: &mut HashMap<NodeIndex, Pt>,
443    root_split: i32,
444) {
445    if visited.contains(v.index()) {
446        return;
447    }
448    visited.insert(v.index());
449
450    let nbrs: Vec<(EdgeIndex, NodeIndex)> = g
451        .base
452        .graph
453        .edges(v)
454        .map(|e| (e.id(), e.target()))
455        .collect();
456
457    for (e, w) in nbrs {
458        let sid = g.get_split(e);
459        if sid <= 0 {
460            continue;
461        }
462        let s = sid as usize;
463        if splits_in_path.len() <= s {
464            splits_in_path.grow(s + 1);
465        }
466        if splits_in_path.contains(s) {
467            continue;
468        } // KEY guard
469        splits_in_path.insert(s);
470
471        let step = if use_weights {
472            g.base.weight(e)
473        } else if sid == root_split {
474            0.1
475        } else {
476            1.0
477        };
478        let Pt(x, y) = *pts.get(&v).unwrap();
479        let a = g.get_angle(e).to_radians();
480        pts.insert(w, Pt(x + step * a.cos(), y + step * a.sin()));
481
482        dfs_coords(use_weights, g, w, visited, splits_in_path, pts, root_split);
483        splits_in_path.set(s, false); // backtrack
484    }
485}
486
487/// Apply leaf labels from taxa labels (if exactly one taxon maps to the leaf), else keep node label
488fn add_leaf_labels_from_taxa(g: &mut PhyloSplitsGraph, taxa: &[String]) {
489    // give labels to leaf nodes with exactly one taxon id
490    let mut updates: Vec<(NodeIndex, String)> = Vec::new();
491    for v in g.base.graph.node_indices() {
492        if g.base.graph.neighbors(v).count() == 1 {
493            if let Some(list) = g.base.node2taxa().expect("No node2taxa map").get(&v) {
494                if list.len() == 1 {
495                    let t = list[0];
496                    if t >= 1 && t <= taxa.len() {
497                        updates.push((v, taxa[t - 1].clone()));
498                    }
499                }
500            }
501        }
502    }
503    for (v, lab) in updates {
504        g.base.set_node_label(v, lab);
505    }
506}
507
508/// Translate Java: EqualAngle.wrapSplit(...)
509/// - `ntax`: number of taxa (1-based domain, i.e., taxa ids are 1..=ntax)
510/// - `cycle`: 1-based array [ _, t1, t2, ..., t_ntax ] (index 0 ignored), already normalized
511pub fn wrap_split(
512    ntax: usize,
513    splits: &SplitsBlock,
514    s: usize,        // split id (1-based)
515    cycle: &[usize], // 1-based cycle
516    graph: &mut PhyloSplitsGraph,
517) -> Result<()> {
518    // Part of split s that does NOT contain taxon 1 (1-based membership)
519    let part = splits.get(s).part_not_containing(1);
520
521    // xp = first taxon in that part along the cycle
522    // xq = last  taxon in that part along the cycle
523    let (mut xp, mut xq) = (0usize, 0usize);
524    for i in 1..=ntax {
525        let t = cycle[i];
526        if part.contains(t) {
527            if xp == 0 {
528                xp = t;
529            }
530            xq = t;
531        }
532    }
533    if xp == 0 || xq == 0 {
534        bail!(
535            "wrap_split: split {} has empty non-1 part after cycle filtering",
536            s
537        );
538    }
539
540    // vp and vq: leaf nodes for taxa xp and xq
541    let vp = graph
542        .base
543        .get_taxon_node(xp)
544        .ok_or_else(|| anyhow!("wrap_split: no node for taxon {}", xp))?;
545    let vq = graph
546        .base
547        .get_taxon_node(xq)
548        .ok_or_else(|| anyhow!("wrap_split: no node for taxon {}", xq))?;
549
550    let e_vp = graph
551        .first_adjacent_edge(vp)
552        .ok_or_else(|| anyhow!("wrap_split: taxon node vp ({:?}) has no incident edge", vp))?;
553
554    let e_vq = graph
555        .first_adjacent_edge(vq)
556        .ok_or_else(|| anyhow!("wrap_split: taxon node vq ({:?}) has no incident edge", vq))?;
557
558    let target_leaf_edge = e_vq; // Java: vq.getFirstAdjacentEdge()
559
560    // Start from vp’s leaf edge and step to the inner neighbor
561    let mut e = e_vp;
562    let mut v = graph.base.get_opposite(vp, e);
563
564    // Leaf edges we’ll copy at the current step (Java accumulates then clears each round)
565    let mut leaf_edges: Vec<EdgeIndex> = Vec::with_capacity(ntax);
566    leaf_edges.push(e);
567    // "nodesVisited" to detect cycles (should not happen)
568    let mut nodes_visited: HashSet<NodeIndex> = HashSet::new();
569
570    // prevU = previous node on the new boundary path
571    let mut prev_u: Option<NodeIndex> = None;
572
573    // Boundary walk
574    loop {
575        debug!(
576            "wrap_split: entering node {:?} via edge {:?} (nodes visits {})",
577            v,
578            e,
579            nodes_visited.len()
580        );
581        if !nodes_visited.insert(v) {
582            // already present
583            bail!("wrap_split: node revisited during wrapping: {:?}", v);
584        }
585
586        // f0 is the edge by which we enter node v
587        let f0 = e;
588
589        // Gather leaf edges incident to v; detect if we reached target_leaf_edge
590        // Also choose the "next" non-leaf boundary edge (not equal to f0).
591        let mut reached_end = false;
592
593        let mut next_e: Option<EdgeIndex> = None;
594
595        // Collect adjacency first to avoid borrow issues while mutating later
596        if let Some(mut f) = graph.next_adjacent_edge_cyclic(v, e) {
597            while graph.is_leaf_edge(f) {
598                leaf_edges.push(f);
599                if f == target_leaf_edge {
600                    break;
601                }
602                if f == f0 {
603                    return Err(anyhow!(
604                        "wrap_split: next adjacent edge cyclic after f0 ({:?}) is also f0; cycle detected at node {:?}",
605                        f0,
606                        v
607                    ));
608                }
609                f = graph.next_adjacent_edge_cyclic(v, f).ok_or_else(|| {
610                    anyhow!(
611                        "wrap_split: no next leaf edge at node {:?} (after entering via {:?})",
612                        v,
613                        e
614                    )
615                })?;
616            }
617
618            if f == target_leaf_edge {
619                next_e = None; // no next edge, we reached the target leaf edge
620                reached_end = true;
621            } else {
622                next_e = Some(f);
623            }
624            debug!(
625                "wrap_split: reached node {:?} edge {:?} (via {:?})",
626                v, f, f0
627            );
628        };
629
630        // Create new node on the NEW path
631        let u = graph.base.new_node();
632
633        // Edge from new node `u` to old node `v` (inserted "after f0" in Java — we can’t control embedding)
634        let f_uv = graph.new_edge_after(u, v, f0)?;
635        graph.set_split(f_uv, s as i32);
636        graph.base.set_weight(f_uv, splits.get(s).weight().into());
637
638        // If we already had a previous `u`, connect it to the current `u` carrying split/weight from `e`
639        if let Some(prev) = prev_u {
640            // Snapshot split/weight on `e` before any mutation
641            let e_split = graph.get_split(e);
642            let e_w = graph.base.weight(e);
643            let f_uprev = graph.new_edge(u, prev)?;
644            graph.set_split(f_uprev, e_split);
645            graph.base.set_weight(f_uprev, e_w);
646        }
647
648        // Copy each leaf edge (v -- w) to (u -- w), copying split/weight; then delete old edge
649        // Snapshot required info before mutation to avoid borrow conflicts
650        let mut copies: Vec<(NodeIndex, i32, f64, EdgeIndex)> =
651            Vec::with_capacity(leaf_edges.len());
652        for f in leaf_edges.iter().copied() {
653            let w = graph.base.get_opposite(v, f);
654            let s_id = graph.get_split(f);
655            let wgt = graph.base.weight(f);
656            copies.push((w, s_id, wgt, f));
657        }
658        for (w, s_id, wgt, f_old) in copies {
659            let f_new = graph.new_edge(u, w)?;
660            graph.set_split(f_new, s_id);
661            graph.base.set_weight(f_new, wgt);
662            graph.remove_edge(f_old); // delete original leaf edge
663        }
664        leaf_edges.clear();
665        // Advance along boundary or stop if we encountered the target leaf edge
666        if reached_end {
667            break;
668        } else {
669            let ne = next_e.ok_or_else(|| {
670                anyhow!(
671                    "wrap_split: no next boundary edge at node {:?} (after entering via {:?})",
672                    v,
673                    f0
674                )
675            })?;
676            v = graph.base.get_opposite(v, ne);
677            e = ne;
678            prev_u = Some(u);
679        }
680    }
681
682    Ok(())
683}
684
685/* ----------------------------- tiny utilities ------------------------------ */
686
687fn first_member(bs: &fixedbitset::FixedBitSet) -> Option<usize> {
688    // our FixedBitSet is 1-based usage; find first set from 1
689    for i in 1..bs.len() {
690        if bs.contains(i) {
691            return Some(i);
692        }
693    }
694    None
695}
696
697fn modulo360(a: f64) -> f64 {
698    let mut x = a % 360.0;
699    if x < 0.0 {
700        x += 360.0;
701    }
702    x
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708    use fixedbitset::FixedBitSet;
709    use ndarray::arr2;
710
711    use crate::{
712        algorithms::equal_angle::{
713            get_nontrivial_splits_ordered, init_graph_star, normalize_cycle_1based,
714        },
715        data::splits_blocks::SplitsBlock,
716        ordering::ordering_huson2023::compute_order_huson_2023,
717        phylo::phylo_splits_graph::PhyloSplitsGraph,
718        utils::compute_least_squares_fit,
719        weights::active_set_weights::{NNLSParams, compute_asplits},
720    };
721
722    #[test]
723    fn smoke_10_1() {
724        let d = arr2(&[
725            [0.0, 5.0, 12.0, 7.0, 3.0, 9.0, 11.0, 6.0, 4.0, 10.0],
726            [5.0, 0.0, 8.0, 2.0, 14.0, 5.0, 13.0, 7.0, 12.0, 1.0],
727            [12.0, 8.0, 0.0, 4.0, 9.0, 3.0, 8.0, 2.0, 5.0, 6.0],
728            [7.0, 2.0, 4.0, 0.0, 11.0, 7.0, 10.0, 4.0, 6.0, 9.0],
729            [3.0, 14.0, 9.0, 11.0, 0.0, 8.0, 1.0, 13.0, 2.0, 7.0],
730            [9.0, 5.0, 3.0, 7.0, 8.0, 0.0, 12.0, 5.0, 3.0, 4.0],
731            [11.0, 13.0, 8.0, 10.0, 1.0, 12.0, 0.0, 6.0, 2.0, 8.0],
732            [6.0, 7.0, 2.0, 4.0, 13.0, 5.0, 6.0, 0.0, 9.0, 7.0],
733            [4.0, 12.0, 5.0, 6.0, 2.0, 3.0, 2.0, 9.0, 0.0, 5.0],
734            [10.0, 1.0, 6.0, 9.0, 7.0, 4.0, 8.0, 7.0, 5.0, 0.0],
735        ]);
736
737        let cycle = compute_order_huson_2023(&d);
738        let mut params = NNLSParams::default();
739        let progress = None; // No progress tracking in this test
740        let labels = vec![
741            "t1".to_string(),
742            "t2".to_string(),
743            "t3".to_string(),
744            "t4".to_string(),
745            "t5".to_string(),
746            "t6".to_string(),
747            "t7".to_string(),
748            "t8".to_string(),
749            "t9".to_string(),
750            "t10".to_string(),
751        ];
752
753        let splits = compute_asplits(&cycle, &d, &mut params, progress).expect("NNLS solve");
754        let fit = compute_least_squares_fit(&d, &splits);
755        let mut splits_blocks = SplitsBlock::new();
756        splits_blocks.set_splits(splits);
757        splits_blocks.set_fit(fit);
758        splits_blocks.set_cycle(cycle, true).expect("set cycle");
759
760        let mut graph = PhyloSplitsGraph::new();
761        // then:
762        let mut used_splits = FixedBitSet::with_capacity(splits_blocks.nsplits() + 1);
763
764        let cycle_exp = vec![0, 1, 5, 7, 9, 3, 8, 4, 2, 10, 6];
765        let c = splits_blocks
766            .cycle()
767            .ok_or_else(|| anyhow::anyhow!("SplitsBlock has no cycle"))
768            .expect("get cycle");
769        let norm_cycle = normalize_cycle_1based(&c).expect("normalize cycle");
770        assert_eq!(norm_cycle, cycle_exp);
771        init_graph_star(&labels, &splits_blocks, &norm_cycle, &mut graph).expect("init graph star");
772        assert!(graph.base.graph.node_count() == 11);
773        assert!(graph.base.graph.edge_count() == 10);
774
775        let ordered = get_nontrivial_splits_ordered(&splits_blocks);
776        assert_eq!(ordered, vec![2, 3, 11, 9, 16, 4, 8, 15, 7, 14, 19, 21]);
777        let ntax = labels.len();
778        let mut all_used = true;
779        for &sid in &ordered {
780            if is_circular_by_cycle(&splits_blocks, sid, &norm_cycle) {
781                // Wrap split sid around the cycle
782                wrap_split(ntax, &splits_blocks, sid, &norm_cycle, &mut graph).expect("wrap split");
783                // Placeholder: log and mark used. We don’t modify graph topology here yet.
784                used_splits.grow(splits_blocks.nsplits() + 1);
785                used_splits.insert(sid);
786                println!("====================");
787                println!("used split {}", sid);
788                println!("{}", graph.base.graph.node_count());
789                println!("{}", graph.base.graph.edge_count());
790            } else {
791                all_used = false;
792            }
793        }
794        println!("all_used = {}", all_used);
795        println!("{}", graph.base.graph.node_count());
796        println!("{}", graph.base.graph.edge_count());
797        assert!(graph.base.graph.node_count() == 41);
798        assert!(graph.base.graph.edge_count() == 58);
799
800        let exp_angles = vec![
801            0.0, 270.0, 288.0, 324.0, 18.0, 54.0, 126.0, 144.0, 162.0, 180.0, 162.0, 234.0, 198.0,
802            234.0, 252.0, 270.0, 288.0, 270.0, 306.0, 324.0, 342.0, 0.0, 18.0,
803        ];
804        let split2angle = assign_angles_to_splits(ntax, &splits_blocks, &norm_cycle, 360.);
805        assert_eq!(split2angle, exp_angles);
806        // equal_angle_apply(
807        //     EqualAngleOpts::default(),
808        //     &labels,
809        //     &splits_blocks,
810        //     &mut graph,
811        //     None,
812        //     &mut used_splits,
813        // ).expect("EqualAngle apply");
814        // println!("{}", graph.base.graph.node_count());
815        // println!("{}", graph.base.graph.edge_count());
816        // assert!(graph.base.graph.node_count() == 42);
817        // assert!(graph.base.graph.edge_count() == 60);
818    }
819}