Skip to main content

sci_form/
stereo.rs

1//! Stereochemistry perception: CIP priority, R/S, and E/Z assignment.
2//!
3//! Implements Cahn–Ingold–Prelog priority rules for chirality perception
4//! using depth-first graph traversal with phantom atom expansion.
5
6use crate::graph::{BondOrder, Molecule};
7use petgraph::graph::NodeIndex;
8use petgraph::visit::EdgeRef;
9use serde::{Deserialize, Serialize};
10
11/// CIP priority tree node for depth-first traversal.
12#[derive(Debug, Clone)]
13struct CipNode {
14    atomic_number: u8,
15    mass: f64,
16    children: Vec<CipNode>,
17}
18
19/// Result of R/S assignment at a single stereocenter.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct Stereocenter {
22    /// Atom index of the chiral center.
23    pub atom_index: usize,
24    /// Atomic number of the center.
25    pub element: u8,
26    /// Indices of the four substituents (CIP priority order: 1 > 2 > 3 > 4).
27    pub substituent_indices: Vec<usize>,
28    /// CIP priorities (1 = highest).
29    pub priorities: Vec<usize>,
30    /// R, S, or None if undetermined.
31    pub configuration: Option<String>,
32}
33
34/// Result of E/Z assignment at a double bond.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct DoubleBondStereo {
37    /// Index of atom on one end of the double bond.
38    pub atom1: usize,
39    /// Index of atom on the other end.
40    pub atom2: usize,
41    /// E, Z, or None if undetermined.
42    pub configuration: Option<String>,
43    /// Highest-priority substituent on atom1 side.
44    pub high_priority_sub1: Option<usize>,
45    /// Highest-priority substituent on atom2 side.
46    pub high_priority_sub2: Option<usize>,
47}
48
49/// Complete stereochemistry analysis result.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct StereoAnalysis {
52    /// Detected stereocenters with R/S assignments.
53    pub stereocenters: Vec<Stereocenter>,
54    /// Detected double bonds with E/Z assignments.
55    pub double_bonds: Vec<DoubleBondStereo>,
56    /// Total number of chiral centers found.
57    pub n_stereocenters: usize,
58    /// Total number of E/Z-assignable double bonds.
59    pub n_double_bonds: usize,
60    /// Detected atropisomeric axes (biaryl axial chirality).
61    pub atropisomeric_axes: Vec<AtropisomericAxis>,
62    /// Detected pro-chiral centers.
63    pub prochiral_centers: Vec<ProchiralCenter>,
64    /// Detected helical chirality (M/P in helicenes, metallocenes).
65    pub helical_chirality: Vec<HelicalChirality>,
66}
67
68/// An atropisomeric axis (restricted rotation around a single bond).
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct AtropisomericAxis {
71    /// Index of atom on one end of the axis bond.
72    pub atom1: usize,
73    /// Index of atom on the other end.
74    pub atom2: usize,
75    /// Number of ortho substituents (indicates steric barrier degree).
76    pub ortho_substituent_count: usize,
77    /// Configuration: aR or aS (if 3D coords available).
78    pub configuration: Option<String>,
79}
80
81/// A pro-chiral center (sp3 with two identical substituents).
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct ProchiralCenter {
84    /// Atom index.
85    pub atom_index: usize,
86    /// Element at the center.
87    pub element: u8,
88    /// Indices of the two enantiotopic substituents (identical pair).
89    pub enantiotopic_pair: [usize; 2],
90}
91
92/// Helical chirality (M/P) detected in helicenes, allenes with extended
93/// conjugation, or metallocenes with non-coplanar rings.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct HelicalChirality {
96    /// Atom indices that form the helical backbone.
97    pub backbone_atoms: Vec<usize>,
98    /// Configuration: "M" (minus, left-handed) or "P" (plus, right-handed).
99    pub configuration: Option<String>,
100    /// Type: "helicene", "allene", "metallocene".
101    pub helix_type: String,
102    /// Dihedral angle of the helix (degrees).
103    pub dihedral_angle: Option<f64>,
104}
105
106/// Approximate atomic mass for CIP tie-breaking.
107fn atomic_mass(z: u8) -> f64 {
108    match z {
109        1 => 1.008,
110        5 => 10.81,
111        6 => 12.011,
112        7 => 14.007,
113        8 => 15.999,
114        9 => 18.998,
115        14 => 28.085,
116        15 => 30.974,
117        16 => 32.06,
118        17 => 35.45,
119        35 => 79.904,
120        53 => 126.904,
121        _ => z as f64,
122    }
123}
124
125/// Build a CIP priority tree rooted at `start` coming from `from`, up to depth `max_depth`.
126fn build_cip_tree(mol: &Molecule, start: usize, from: usize, max_depth: usize) -> CipNode {
127    let start_idx = NodeIndex::new(start);
128    let atom = &mol.graph[start_idx];
129
130    let mut node = CipNode {
131        atomic_number: atom.element,
132        mass: atomic_mass(atom.element),
133        children: Vec::new(),
134    };
135
136    if max_depth == 0 {
137        return node;
138    }
139
140    // Collect neighbors excluding `from`
141    for neighbor in mol.graph.neighbors(start_idx) {
142        let ni = neighbor.index();
143        if ni == from {
144            continue;
145        }
146
147        // Find the bond order between start and neighbor
148        let edge = mol.graph.find_edge(start_idx, neighbor);
149        let bond_order = edge
150            .map(|e| &mol.graph[e].order)
151            .unwrap_or(&BondOrder::Single);
152
153        // For double/triple bonds, add phantom atoms (duplicates)
154        let n_phantoms = match bond_order {
155            BondOrder::Double | BondOrder::Aromatic => 1,
156            BondOrder::Triple => 2,
157            _ => 0,
158        };
159
160        // Real child
161        node.children
162            .push(build_cip_tree(mol, ni, start, max_depth - 1));
163
164        // Phantom children (copies of the neighbor with no further children)
165        let neighbor_atom = &mol.graph[neighbor];
166        for _ in 0..n_phantoms {
167            node.children.push(CipNode {
168                atomic_number: neighbor_atom.element,
169                mass: atomic_mass(neighbor_atom.element),
170                children: Vec::new(),
171            });
172        }
173
174        // Add phantom of start to neighbor too (symmetry of double bond expansion)
175        // This is handled by the child's own tree construction.
176    }
177
178    // Sort children by CIP priority (higher atomic number = higher priority)
179    node.children.sort_by(|a, b| cip_compare(b, a));
180
181    node
182}
183
184/// Compare two CIP nodes (higher = "greater").
185/// Returns Ordering for descending sort (highest priority first).
186fn cip_compare(a: &CipNode, b: &CipNode) -> std::cmp::Ordering {
187    // Rule 1: Higher atomic number wins
188    match a.atomic_number.cmp(&b.atomic_number) {
189        std::cmp::Ordering::Equal => {}
190        other => return other,
191    }
192
193    // Rule 2: Higher mass wins (isotope rule)
194    match a.mass.partial_cmp(&b.mass) {
195        Some(std::cmp::Ordering::Equal) | None => {}
196        Some(other) => return other,
197    }
198
199    // Rule 3: Compare children layer by layer
200    let max_children = a.children.len().max(b.children.len());
201    for i in 0..max_children {
202        match (a.children.get(i), b.children.get(i)) {
203            (Some(ca), Some(cb)) => match cip_compare(ca, cb) {
204                std::cmp::Ordering::Equal => continue,
205                other => return other,
206            },
207            (Some(_), None) => return std::cmp::Ordering::Greater,
208            (None, Some(_)) => return std::cmp::Ordering::Less,
209            (None, None) => break,
210        }
211    }
212
213    std::cmp::Ordering::Equal
214}
215
216/// Detect stereocenters: sp3 atoms with 4 different substituents.
217fn find_stereocenters(mol: &Molecule) -> Vec<usize> {
218    let mut centers = Vec::new();
219
220    for node in mol.graph.node_indices() {
221        let atom = &mol.graph[node];
222        // Skip H and uncommon centers
223        if atom.element == 1 {
224            continue;
225        }
226
227        let neighbors: Vec<usize> = mol.graph.neighbors(node).map(|n| n.index()).collect();
228
229        // Need exactly 4 substituents for sp3 chiral center
230        if neighbors.len() != 4 {
231            continue;
232        }
233
234        // Build CIP trees for each substituent
235        let trees: Vec<CipNode> = neighbors
236            .iter()
237            .map(|&n| build_cip_tree(mol, n, node.index(), 6))
238            .collect();
239
240        // Check all pairs are distinct
241        let mut all_different = true;
242        'outer: for i in 0..4 {
243            for j in (i + 1)..4 {
244                if cip_compare(&trees[i], &trees[j]) == std::cmp::Ordering::Equal {
245                    all_different = false;
246                    break 'outer;
247                }
248            }
249        }
250
251        if all_different {
252            centers.push(node.index());
253        }
254    }
255
256    centers
257}
258
259/// Assign CIP priorities to substituents around a center atom.
260/// Returns (sorted_neighbor_indices, priority_ranks) where rank 1 = highest.
261fn assign_cip_priorities(
262    mol: &Molecule,
263    center: usize,
264    neighbors: &[usize],
265) -> (Vec<usize>, Vec<usize>) {
266    let mut indexed_trees: Vec<(usize, CipNode)> = neighbors
267        .iter()
268        .map(|&n| (n, build_cip_tree(mol, n, center, 6)))
269        .collect();
270
271    // Sort by descending CIP priority
272    indexed_trees.sort_by(|a, b| cip_compare(&b.1, &a.1));
273
274    let sorted_indices: Vec<usize> = indexed_trees.iter().map(|(idx, _)| *idx).collect();
275    let priorities: Vec<usize> = (1..=sorted_indices.len()).collect();
276
277    (sorted_indices, priorities)
278}
279
280/// Determine R/S configuration from 3D coordinates.
281///
282/// Uses the signed volume of the tetrahedron formed by substituents 1-2-3
283/// viewed from substituent 4 (lowest priority).
284/// Positive volume with 1→2→3 going clockwise = R.
285fn assign_rs(positions: &[[f64; 3]], center: usize, sorted_subs: &[usize]) -> Option<String> {
286    if sorted_subs.len() != 4 || positions.is_empty() {
287        return None;
288    }
289    if center >= positions.len() || sorted_subs.iter().any(|&s| s >= positions.len()) {
290        return None;
291    }
292
293    // Vector from lowest-priority (4th) substituent to center
294    let p4 = positions[sorted_subs[3]];
295    let p1 = positions[sorted_subs[0]];
296    let p2 = positions[sorted_subs[1]];
297    let p3 = positions[sorted_subs[2]];
298
299    // Vectors from p4 to p1, p2, p3
300    let v1 = [p1[0] - p4[0], p1[1] - p4[1], p1[2] - p4[2]];
301    let v2 = [p2[0] - p4[0], p2[1] - p4[1], p2[2] - p4[2]];
302    let v3 = [p3[0] - p4[0], p3[1] - p4[1], p3[2] - p4[2]];
303
304    // Signed volume = v1 · (v2 × v3)
305    let cross = [
306        v2[1] * v3[2] - v2[2] * v3[1],
307        v2[2] * v3[0] - v2[0] * v3[2],
308        v2[0] * v3[1] - v2[1] * v3[0],
309    ];
310    let signed_volume = v1[0] * cross[0] + v1[1] * cross[1] + v1[2] * cross[2];
311
312    if signed_volume.abs() < 1e-6 {
313        return None; // Planar, can't determine
314    }
315
316    Some(if signed_volume > 0.0 {
317        "R".to_string()
318    } else {
319        "S".to_string()
320    })
321}
322
323/// Find double bonds that could have E/Z isomerism.
324fn find_ez_double_bonds(mol: &Molecule) -> Vec<(usize, usize)> {
325    let mut bonds = Vec::new();
326
327    for edge in mol.graph.edge_indices() {
328        let bond = &mol.graph[edge];
329        if bond.order != BondOrder::Double {
330            continue;
331        }
332
333        let (a, b) = mol.graph.edge_endpoints(edge).unwrap();
334        let a_idx = a.index();
335        let b_idx = b.index();
336
337        // Each end needs at least 2 different substituents (including the other end)
338        let a_neighbors: Vec<usize> = mol
339            .graph
340            .neighbors(a)
341            .map(|n| n.index())
342            .filter(|&n| n != b_idx)
343            .collect();
344        let b_neighbors: Vec<usize> = mol
345            .graph
346            .neighbors(b)
347            .map(|n| n.index())
348            .filter(|&n| n != a_idx)
349            .collect();
350
351        // Need at least 1 substituent on each side (besides the double bond partner)
352        if a_neighbors.is_empty() || b_neighbors.is_empty() {
353            continue;
354        }
355
356        // If only 1 substituent on a side, we can still assign E/Z
357        // If 2 substituents, they must be different
358        if a_neighbors.len() >= 2 {
359            let trees_a: Vec<CipNode> = a_neighbors
360                .iter()
361                .map(|&n| build_cip_tree(mol, n, a_idx, 6))
362                .collect();
363            if cip_compare(&trees_a[0], &trees_a[1]) == std::cmp::Ordering::Equal {
364                continue; // Identical substituents on atom a
365            }
366        }
367        if b_neighbors.len() >= 2 {
368            let trees_b: Vec<CipNode> = b_neighbors
369                .iter()
370                .map(|&n| build_cip_tree(mol, n, b_idx, 6))
371                .collect();
372            if cip_compare(&trees_b[0], &trees_b[1]) == std::cmp::Ordering::Equal {
373                continue;
374            }
375        }
376
377        bonds.push((a_idx, b_idx));
378    }
379
380    bonds
381}
382
383/// Assign E/Z from 3D coordinates using the dihedral angle of highest-priority
384/// substituents across the double bond.
385fn assign_ez(
386    mol: &Molecule,
387    positions: &[[f64; 3]],
388    atom1: usize,
389    atom2: usize,
390) -> DoubleBondStereo {
391    let a_idx = NodeIndex::new(atom1);
392    let b_idx = NodeIndex::new(atom2);
393
394    let mut a_subs: Vec<usize> = mol
395        .graph
396        .neighbors(a_idx)
397        .map(|n| n.index())
398        .filter(|&n| n != atom2)
399        .collect();
400    let mut b_subs: Vec<usize> = mol
401        .graph
402        .neighbors(b_idx)
403        .map(|n| n.index())
404        .filter(|&n| n != atom1)
405        .collect();
406
407    // Sort by CIP priority (highest first)
408    a_subs.sort_by(|&x, &y| {
409        let tx = build_cip_tree(mol, x, atom1, 6);
410        let ty = build_cip_tree(mol, y, atom1, 6);
411        cip_compare(&ty, &tx)
412    });
413    b_subs.sort_by(|&x, &y| {
414        let tx = build_cip_tree(mol, x, atom2, 6);
415        let ty = build_cip_tree(mol, y, atom2, 6);
416        cip_compare(&ty, &tx)
417    });
418
419    let high_a = a_subs.first().copied();
420    let high_b = b_subs.first().copied();
421
422    let configuration = match (high_a, high_b) {
423        (Some(ha), Some(hb)) if !positions.is_empty() => {
424            if atom1 >= positions.len()
425                || atom2 >= positions.len()
426                || ha >= positions.len()
427                || hb >= positions.len()
428            {
429                None
430            } else {
431                // Compute dihedral angle ha-atom1-atom2-hb
432                let dihedral = compute_dihedral(
433                    &positions[ha],
434                    &positions[atom1],
435                    &positions[atom2],
436                    &positions[hb],
437                );
438                if dihedral.abs() < std::f64::consts::FRAC_PI_2 {
439                    Some("Z".to_string()) // same side
440                } else {
441                    Some("E".to_string()) // opposite side
442                }
443            }
444        }
445        _ => None,
446    };
447
448    DoubleBondStereo {
449        atom1,
450        atom2,
451        configuration,
452        high_priority_sub1: high_a,
453        high_priority_sub2: high_b,
454    }
455}
456
457/// Compute dihedral angle (in radians) for four points a-b-c-d.
458fn compute_dihedral(a: &[f64; 3], b: &[f64; 3], c: &[f64; 3], d: &[f64; 3]) -> f64 {
459    let b1 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
460    let b2 = [c[0] - b[0], c[1] - b[1], c[2] - b[2]];
461    let b3 = [d[0] - c[0], d[1] - c[1], d[2] - c[2]];
462
463    let cross_b1_b2 = cross(&b1, &b2);
464    let cross_b2_b3 = cross(&b2, &b3);
465
466    let n1_dot_n2 = dot(&cross_b1_b2, &cross_b2_b3);
467    let b2_norm = dot(&b2, &b2).sqrt();
468
469    let x = n1_dot_n2;
470    let y = dot(&cross(&cross_b1_b2, &cross_b2_b3), &b2) / b2_norm.max(1e-12);
471
472    y.atan2(x)
473}
474
475fn cross(a: &[f64; 3], b: &[f64; 3]) -> [f64; 3] {
476    [
477        a[1] * b[2] - a[2] * b[1],
478        a[2] * b[0] - a[0] * b[2],
479        a[0] * b[1] - a[1] * b[0],
480    ]
481}
482
483fn dot(a: &[f64; 3], b: &[f64; 3]) -> f64 {
484    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
485}
486
487/// Perform complete stereochemistry analysis on a molecule.
488///
489/// Detects chiral centers (R/S), E/Z double bonds, atropisomeric axes,
490/// and pro-chiral centers from SMILES topology and optional 3D coordinates.
491pub fn analyze_stereo(mol: &Molecule, positions: &[[f64; 3]]) -> StereoAnalysis {
492    // Find and assign stereocenters
493    let center_indices = find_stereocenters(mol);
494    let stereocenters: Vec<Stereocenter> = center_indices
495        .iter()
496        .map(|&center| {
497            let center_idx = NodeIndex::new(center);
498            let neighbors: Vec<usize> =
499                mol.graph.neighbors(center_idx).map(|n| n.index()).collect();
500            let (sorted_subs, priorities) = assign_cip_priorities(mol, center, &neighbors);
501            let configuration = if !positions.is_empty() {
502                assign_rs(positions, center, &sorted_subs)
503            } else {
504                None
505            };
506
507            Stereocenter {
508                atom_index: center,
509                element: mol.graph[center_idx].element,
510                substituent_indices: sorted_subs,
511                priorities,
512                configuration,
513            }
514        })
515        .collect();
516
517    // Find and assign E/Z double bonds
518    let ez_bonds = find_ez_double_bonds(mol);
519    let double_bonds: Vec<DoubleBondStereo> = ez_bonds
520        .iter()
521        .map(|&(a, b)| assign_ez(mol, positions, a, b))
522        .collect();
523
524    // Find atropisomeric axes
525    let atropisomeric_axes = find_atropisomeric_axes(mol, positions);
526
527    // Find pro-chiral centers
528    let prochiral_centers = find_prochiral_centers(mol);
529
530    let n_stereocenters = stereocenters.len();
531    let n_double_bonds = double_bonds.len();
532
533    StereoAnalysis {
534        stereocenters,
535        double_bonds,
536        n_stereocenters,
537        n_double_bonds,
538        atropisomeric_axes,
539        prochiral_centers,
540        helical_chirality: find_helical_chirality(mol, positions),
541    }
542}
543
544/// Detect helical chirality (M/P) in helicenes and metallocenes.
545///
546/// Helical chirality arises when a molecule forms a non-planar spiral.
547/// M (minus) = left-handed helix, P (plus) = right-handed helix.
548fn find_helical_chirality(mol: &Molecule, positions: &[[f64; 3]]) -> Vec<HelicalChirality> {
549    let mut helices = Vec::new();
550
551    if positions.is_empty() {
552        return helices;
553    }
554
555    // Detect allenic/cumulenic systems: X=Y=Z with helical potential
556    // Supports carbon allenes (C=C=C) and heteroatom cumulenes (C=C=N, etc.)
557    for node in mol.graph.node_indices() {
558        let atom = &mol.graph[node];
559        // Allow C, N, S as cumulated double bond centers (allenes, ketenimines, etc.)
560        if !matches!(atom.element, 6 | 7 | 16) {
561            continue;
562        }
563
564        // Check for cumulated double bonds: =C=
565        let double_neighbors: Vec<usize> = mol
566            .graph
567            .edges(node)
568            .filter(|e| mol.graph[e.id()].order == BondOrder::Double)
569            .map(|e| {
570                let (a, b) = mol.graph.edge_endpoints(e.id()).unwrap();
571                if a == node {
572                    b.index()
573                } else {
574                    a.index()
575                }
576            })
577            .collect();
578
579        if double_neighbors.len() == 2 {
580            // Cumulenic system: X=Y=Z (allene, ketenimine, etc.)
581            let a = double_neighbors[0];
582            let center = node.index();
583            let b = double_neighbors[1];
584
585            // Get terminal substituents for M/P assignment
586            let a_subs: Vec<usize> = mol
587                .graph
588                .neighbors(NodeIndex::new(a))
589                .filter(|&n| n.index() != center)
590                .map(|n| n.index())
591                .collect();
592            let b_subs: Vec<usize> = mol
593                .graph
594                .neighbors(NodeIndex::new(b))
595                .filter(|&n| n.index() != center)
596                .map(|n| n.index())
597                .collect();
598
599            if a_subs.len() >= 2 && b_subs.len() >= 2 {
600                let dihedral = if positions.len() > a_subs[0] && positions.len() > b_subs[0] {
601                    Some(compute_dihedral(
602                        &positions[a_subs[0]],
603                        &positions[a],
604                        &positions[b],
605                        &positions[b_subs[0]],
606                    ))
607                } else {
608                    None
609                };
610
611                let config = dihedral.map(|d| {
612                    if d > 0.0 {
613                        "P".to_string()
614                    } else {
615                        "M".to_string()
616                    }
617                });
618
619                helices.push(HelicalChirality {
620                    backbone_atoms: vec![a, center, b],
621                    configuration: config,
622                    helix_type: if atom.element == 6 {
623                        "allene"
624                    } else {
625                        "cumulene"
626                    }
627                    .to_string(),
628                    dihedral_angle: dihedral,
629                });
630            }
631        }
632    }
633
634    // Detect helicene-like fused aromatic rings with non-planarity
635    // Look for sequences of 4+ fused aromatic rings that curve
636    let aromatic_atoms: Vec<usize> = mol
637        .graph
638        .node_indices()
639        .filter(|&n| {
640            mol.graph
641                .edges(n)
642                .any(|e| mol.graph[e.id()].order == BondOrder::Aromatic)
643        })
644        .map(|n| n.index())
645        .collect();
646
647    if aromatic_atoms.len() >= 10 {
648        // Check if the aromatic system is non-planar
649        if let Some(planarity_dev) = measure_planarity(&aromatic_atoms, positions) {
650            if planarity_dev > 0.3 {
651                // Non-planar aromatic system → potential helicene
652                // Determine M/P from the sign of the average torsion
653                let avg_torsion = compute_average_torsion(&aromatic_atoms, positions);
654                let config = avg_torsion.map(|t| {
655                    if t > 0.0 {
656                        "P".to_string()
657                    } else {
658                        "M".to_string()
659                    }
660                });
661
662                helices.push(HelicalChirality {
663                    backbone_atoms: aromatic_atoms.clone(),
664                    configuration: config,
665                    helix_type: "helicene".to_string(),
666                    dihedral_angle: avg_torsion,
667                });
668            }
669        }
670    }
671
672    // Detect metallocene helical chirality (tilted Cp rings)
673    for node in mol.graph.node_indices() {
674        let atom = &mol.graph[node];
675        // Check if this is a transition metal
676        if !is_transition_metal(atom.element) {
677            continue;
678        }
679
680        let metal_idx = node.index();
681        let neighbors: Vec<usize> = mol.graph.neighbors(node).map(|n| n.index()).collect();
682
683        // Find groups of aromatic ring atoms bonded to the metal
684        let ring_neighbors: Vec<usize> = neighbors
685            .iter()
686            .filter(|&&n| {
687                mol.graph
688                    .edges(NodeIndex::new(n))
689                    .any(|e| mol.graph[e.id()].order == BondOrder::Aromatic)
690            })
691            .copied()
692            .collect();
693
694        if ring_neighbors.len() >= 4 {
695            // Potential metallocene with tilted rings
696            let ring_torsion = compute_ring_tilt(&ring_neighbors, &positions[metal_idx], positions);
697            let config = ring_torsion.map(|t| {
698                if t > 0.0 {
699                    "P".to_string()
700                } else {
701                    "M".to_string()
702                }
703            });
704
705            let mut backbone = vec![metal_idx];
706            backbone.extend_from_slice(&ring_neighbors);
707
708            helices.push(HelicalChirality {
709                backbone_atoms: backbone,
710                configuration: config,
711                helix_type: "metallocene".to_string(),
712                dihedral_angle: ring_torsion,
713            });
714        }
715    }
716
717    helices
718}
719
720fn is_transition_metal(z: u8) -> bool {
721    matches!(z, 21..=30 | 39..=48 | 72..=80)
722}
723
724/// Measure planarity deviation (RMSD from best-fit plane) in ångströms.
725fn measure_planarity(atom_indices: &[usize], positions: &[[f64; 3]]) -> Option<f64> {
726    if atom_indices.len() < 3 || positions.is_empty() {
727        return None;
728    }
729
730    // Compute centroid
731    let n = atom_indices.len() as f64;
732    let mut cz = 0.0;
733    for &idx in atom_indices {
734        if idx >= positions.len() {
735            return None;
736        }
737        cz += positions[idx][2];
738    }
739    cz /= n;
740
741    // Simple planarity check: average |z - z_mean| after centering
742    let dev: f64 = atom_indices
743        .iter()
744        .map(|&idx| {
745            let dz = positions[idx][2] - cz;
746            dz * dz
747        })
748        .sum::<f64>()
749        / n;
750
751    Some(dev.sqrt())
752}
753
754/// Compute average torsion along a set of atoms (for helicene sign).
755fn compute_average_torsion(atoms: &[usize], positions: &[[f64; 3]]) -> Option<f64> {
756    if atoms.len() < 4 {
757        return None;
758    }
759
760    let mut total_torsion = 0.0;
761    let mut count = 0;
762    for i in 0..atoms.len().saturating_sub(3) {
763        let a = atoms[i];
764        let b = atoms[i + 1];
765        let c = atoms[i + 2];
766        let d = atoms[i + 3];
767        if a < positions.len() && b < positions.len() && c < positions.len() && d < positions.len()
768        {
769            total_torsion +=
770                compute_dihedral(&positions[a], &positions[b], &positions[c], &positions[d]);
771            count += 1;
772        }
773    }
774
775    if count > 0 {
776        Some(total_torsion / count as f64)
777    } else {
778        None
779    }
780}
781
782/// Compute ring tilt angle for metallocene helical chirality.
783fn compute_ring_tilt(
784    ring_atoms: &[usize],
785    _metal_pos: &[f64; 3],
786    positions: &[[f64; 3]],
787) -> Option<f64> {
788    if ring_atoms.len() < 4 {
789        return None;
790    }
791
792    // Use first 4 ring atoms to compute a torsion that indicates tilt
793    let a = ring_atoms[0];
794    let b = ring_atoms[1];
795    let c = ring_atoms[2];
796    let d = ring_atoms[3];
797
798    if a < positions.len() && b < positions.len() && c < positions.len() && d < positions.len() {
799        Some(compute_dihedral(
800            &positions[a],
801            &positions[b],
802            &positions[c],
803            &positions[d],
804        ))
805    } else {
806        None
807    }
808}
809
810/// Detect atropisomeric axes: biaryl single bonds with ortho substituents
811/// that create a steric barrier to rotation.
812fn find_atropisomeric_axes(mol: &Molecule, positions: &[[f64; 3]]) -> Vec<AtropisomericAxis> {
813    let mut axes = Vec::new();
814
815    for edge in mol.graph.edge_indices() {
816        let bond = &mol.graph[edge];
817        // Atropisomerism occurs at single bonds between aromatic rings
818        if bond.order != BondOrder::Single {
819            continue;
820        }
821
822        let (a, b) = mol.graph.edge_endpoints(edge).unwrap();
823        let a_idx = a.index();
824        let b_idx = b.index();
825
826        // Both atoms must be in aromatic rings (biaryl)
827        let a_aromatic = mol
828            .graph
829            .edges(a)
830            .any(|e| mol.graph[e.id()].order == BondOrder::Aromatic);
831        let b_aromatic = mol
832            .graph
833            .edges(b)
834            .any(|e| mol.graph[e.id()].order == BondOrder::Aromatic);
835
836        if !a_aromatic || !b_aromatic {
837            continue;
838        }
839
840        // Count ortho substituents (neighbors of each atom that are not on the
841        // aromatic ring between a and b, and are not H)
842        let a_neighbors: Vec<usize> = mol
843            .graph
844            .neighbors(a)
845            .map(|n| n.index())
846            .filter(|&n| n != b_idx && mol.graph[NodeIndex::new(n)].element != 1)
847            .collect();
848        let b_neighbors: Vec<usize> = mol
849            .graph
850            .neighbors(b)
851            .map(|n| n.index())
852            .filter(|&n| n != a_idx && mol.graph[NodeIndex::new(n)].element != 1)
853            .collect();
854
855        // Need at least 2 ortho substituents on each side (total ≥ 3 for restricted rotation)
856        let ortho_count = a_neighbors.len() + b_neighbors.len();
857        if ortho_count < 3 {
858            continue;
859        }
860
861        // Check if substituents are large enough (non-H neighbors of ortho atoms)
862        let has_bulky = a_neighbors.iter().chain(b_neighbors.iter()).any(|&n| {
863            let n_idx = NodeIndex::new(n);
864            mol.graph
865                .neighbors(n_idx)
866                .any(|nb| mol.graph[nb].element != 1 && nb.index() != a_idx && nb.index() != b_idx)
867        });
868
869        if !has_bulky {
870            continue;
871        }
872
873        let configuration =
874            if !positions.is_empty() && a_idx < positions.len() && b_idx < positions.len() {
875                // Use dihedral of highest-priority ortho subs to determine aR/aS
876                if let (Some(&sub_a), Some(&sub_b)) = (a_neighbors.first(), b_neighbors.first()) {
877                    if sub_a < positions.len() && sub_b < positions.len() {
878                        let dihedral = compute_dihedral(
879                            &positions[sub_a],
880                            &positions[a_idx],
881                            &positions[b_idx],
882                            &positions[sub_b],
883                        );
884                        Some(if dihedral > 0.0 { "aR" } else { "aS" }.to_string())
885                    } else {
886                        None
887                    }
888                } else {
889                    None
890                }
891            } else {
892                None
893            };
894
895        axes.push(AtropisomericAxis {
896            atom1: a_idx,
897            atom2: b_idx,
898            ortho_substituent_count: ortho_count,
899            configuration,
900        });
901    }
902
903    axes
904}
905
906/// Detect pro-chiral centers: sp3 atoms with exactly two identical substituents.
907fn find_prochiral_centers(mol: &Molecule) -> Vec<ProchiralCenter> {
908    let mut centers = Vec::new();
909
910    for node in mol.graph.node_indices() {
911        let atom = &mol.graph[node];
912        if atom.element == 1 {
913            continue;
914        }
915
916        let neighbors: Vec<usize> = mol.graph.neighbors(node).map(|n| n.index()).collect();
917
918        // Need exactly 4 substituents (sp3)
919        if neighbors.len() != 4 {
920            continue;
921        }
922
923        // Build CIP trees
924        let trees: Vec<CipNode> = neighbors
925            .iter()
926            .map(|&n| build_cip_tree(mol, n, node.index(), 6))
927            .collect();
928
929        // Find pairs with identical CIP priority
930        for i in 0..4 {
931            for j in (i + 1)..4 {
932                if cip_compare(&trees[i], &trees[j]) == std::cmp::Ordering::Equal {
933                    // Check that the other two are different from each other and from the pair
934                    let others: Vec<usize> = (0..4).filter(|&k| k != i && k != j).collect();
935                    let other_different = cip_compare(&trees[others[0]], &trees[others[1]])
936                        != std::cmp::Ordering::Equal
937                        && cip_compare(&trees[i], &trees[others[0]]) != std::cmp::Ordering::Equal;
938
939                    if other_different {
940                        centers.push(ProchiralCenter {
941                            atom_index: node.index(),
942                            element: atom.element,
943                            enantiotopic_pair: [neighbors[i], neighbors[j]],
944                        });
945                    }
946                    break; // Only one pair per center
947                }
948            }
949        }
950    }
951
952    centers
953}
954
955/// Generate SMILES-style stereo descriptors from a stereochemistry analysis.
956///
957/// Returns a map of atom indices to their stereo descriptor strings:
958/// - Chiral centers: "@" (S-configuration) or "@@" (R-configuration)
959/// - Double bond atoms: "/" or "\" for E/Z geometry
960///
961/// These descriptors can be embedded into SMILES strings to encode stereochemistry.
962pub fn stereo_descriptors(analysis: &StereoAnalysis) -> StereoDescriptors {
963    let mut center_descriptors = Vec::new();
964    let mut bond_descriptors = Vec::new();
965
966    // R/S → @/@@
967    for sc in &analysis.stereocenters {
968        if let Some(ref config) = sc.configuration {
969            let descriptor = match config.as_str() {
970                "S" => "@".to_string(),
971                "R" => "@@".to_string(),
972                _ => continue,
973            };
974            center_descriptors.push(StereoAtomDescriptor {
975                atom_index: sc.atom_index,
976                descriptor,
977                configuration: config.clone(),
978            });
979        }
980    }
981
982    // E/Z → / and \
983    for db in &analysis.double_bonds {
984        if let Some(ref config) = db.configuration {
985            let (desc1, desc2) = match config.as_str() {
986                "E" => ("/".to_string(), "/".to_string()),
987                "Z" => ("/".to_string(), "\\".to_string()),
988                _ => continue,
989            };
990            bond_descriptors.push(StereoBondDescriptor {
991                atom1: db.atom1,
992                atom2: db.atom2,
993                descriptor_atom1: desc1,
994                descriptor_atom2: desc2,
995                configuration: config.clone(),
996            });
997        }
998    }
999
1000    StereoDescriptors {
1001        centers: center_descriptors,
1002        bonds: bond_descriptors,
1003    }
1004}
1005
1006/// Collection of stereo descriptors for a molecule.
1007#[derive(Debug, Clone, Serialize, Deserialize)]
1008pub struct StereoDescriptors {
1009    /// Stereo descriptors for chiral centers.
1010    pub centers: Vec<StereoAtomDescriptor>,
1011    /// Stereo descriptors for double bonds.
1012    pub bonds: Vec<StereoBondDescriptor>,
1013}
1014
1015/// Stereo descriptor for a single chiral center.
1016#[derive(Debug, Clone, Serialize, Deserialize)]
1017pub struct StereoAtomDescriptor {
1018    /// Atom index of the chiral center.
1019    pub atom_index: usize,
1020    /// SMILES descriptor: "@" or "@@".
1021    pub descriptor: String,
1022    /// Configuration: "R" or "S".
1023    pub configuration: String,
1024}
1025
1026/// Stereo descriptor for a double bond.
1027#[derive(Debug, Clone, Serialize, Deserialize)]
1028pub struct StereoBondDescriptor {
1029    /// First atom of the double bond.
1030    pub atom1: usize,
1031    /// Second atom of the double bond.
1032    pub atom2: usize,
1033    /// SMILES bond descriptor for the atom1 side.
1034    pub descriptor_atom1: String,
1035    /// SMILES bond descriptor for the atom2 side.
1036    pub descriptor_atom2: String,
1037    /// Configuration: "E" or "Z".
1038    pub configuration: String,
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044
1045    #[test]
1046    fn test_no_stereocenters_ethane() {
1047        let mol = Molecule::from_smiles("CC").unwrap();
1048        let result = analyze_stereo(&mol, &[]);
1049        assert_eq!(result.n_stereocenters, 0);
1050    }
1051
1052    #[test]
1053    fn test_stereocenter_detection_chiral() {
1054        // 2-bromobutane: C(Br)(CC)C — central carbon has 4 different substituents
1055        let mol = Molecule::from_smiles("CC(Br)CC").unwrap();
1056        let result = analyze_stereo(&mol, &[]);
1057        assert!(
1058            result.n_stereocenters >= 1,
1059            "2-bromobutane should have at least 1 stereocenter, got {}",
1060            result.n_stereocenters
1061        );
1062    }
1063
1064    #[test]
1065    fn test_cip_priority_ordering() {
1066        // Br > Cl > F > O > N > C > H (atomic number)
1067        let mol = Molecule::from_smiles("C(F)(Cl)Br").unwrap();
1068        let result = analyze_stereo(&mol, &[]);
1069        if let Some(sc) = result.stereocenters.first() {
1070            // The Br neighbor should have priority 1, Cl 2, F 3
1071            let elements: Vec<u8> = sc
1072                .substituent_indices
1073                .iter()
1074                .map(|&i| mol.graph[NodeIndex::new(i)].element)
1075                .collect();
1076            // First should be Br(35), then Cl(17), then F(9), then H(1)
1077            assert!(
1078                elements[0] >= elements[1],
1079                "CIP order wrong: first {} should be >= second {}",
1080                elements[0],
1081                elements[1]
1082            );
1083        }
1084    }
1085
1086    #[test]
1087    fn test_ez_detection_2_butene() {
1088        // 2-butene: CC=CC has a double bond with different substituents
1089        let mol = Molecule::from_smiles("CC=CC").unwrap();
1090        let result = analyze_stereo(&mol, &[]);
1091        assert!(
1092            result.n_double_bonds >= 1,
1093            "2-butene should have E/Z-assignable double bond, got {}",
1094            result.n_double_bonds
1095        );
1096    }
1097
1098    #[test]
1099    fn test_no_ez_ethylene() {
1100        // Ethylene C=C — same substituents on each side (H, H)
1101        let mol = Molecule::from_smiles("C=C").unwrap();
1102        let result = analyze_stereo(&mol, &[]);
1103        assert_eq!(
1104            result.n_double_bonds, 0,
1105            "Ethylene should have no E/Z double bonds"
1106        );
1107    }
1108
1109    #[test]
1110    fn test_benzene_no_stereo() {
1111        let mol = Molecule::from_smiles("c1ccccc1").unwrap();
1112        let result = analyze_stereo(&mol, &[]);
1113        assert_eq!(result.n_stereocenters, 0);
1114    }
1115}