1use crate::graph::{BondOrder, Molecule};
7use petgraph::graph::NodeIndex;
8use petgraph::visit::EdgeRef;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone)]
13struct CipNode {
14 atomic_number: u8,
15 mass: f64,
16 children: Vec<CipNode>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct Stereocenter {
22 pub atom_index: usize,
24 pub element: u8,
26 pub substituent_indices: Vec<usize>,
28 pub priorities: Vec<usize>,
30 pub configuration: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct DoubleBondStereo {
37 pub atom1: usize,
39 pub atom2: usize,
41 pub configuration: Option<String>,
43 pub high_priority_sub1: Option<usize>,
45 pub high_priority_sub2: Option<usize>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct StereoAnalysis {
52 pub stereocenters: Vec<Stereocenter>,
54 pub double_bonds: Vec<DoubleBondStereo>,
56 pub n_stereocenters: usize,
58 pub n_double_bonds: usize,
60 pub atropisomeric_axes: Vec<AtropisomericAxis>,
62 pub prochiral_centers: Vec<ProchiralCenter>,
64 pub helical_chirality: Vec<HelicalChirality>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct AtropisomericAxis {
71 pub atom1: usize,
73 pub atom2: usize,
75 pub ortho_substituent_count: usize,
77 pub configuration: Option<String>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct ProchiralCenter {
84 pub atom_index: usize,
86 pub element: u8,
88 pub enantiotopic_pair: [usize; 2],
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct HelicalChirality {
96 pub backbone_atoms: Vec<usize>,
98 pub configuration: Option<String>,
100 pub helix_type: String,
102 pub dihedral_angle: Option<f64>,
104}
105
106fn 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
125fn 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 for neighbor in mol.graph.neighbors(start_idx) {
142 let ni = neighbor.index();
143 if ni == from {
144 continue;
145 }
146
147 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 let n_phantoms = match bond_order {
155 BondOrder::Double | BondOrder::Aromatic => 1,
156 BondOrder::Triple => 2,
157 _ => 0,
158 };
159
160 node.children
162 .push(build_cip_tree(mol, ni, start, max_depth - 1));
163
164 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 }
177
178 node.children.sort_by(|a, b| cip_compare(b, a));
180
181 node
182}
183
184fn cip_compare(a: &CipNode, b: &CipNode) -> std::cmp::Ordering {
187 match a.atomic_number.cmp(&b.atomic_number) {
189 std::cmp::Ordering::Equal => {}
190 other => return other,
191 }
192
193 match a.mass.partial_cmp(&b.mass) {
195 Some(std::cmp::Ordering::Equal) | None => {}
196 Some(other) => return other,
197 }
198
199 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
216fn 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 if atom.element == 1 {
224 continue;
225 }
226
227 let neighbors: Vec<usize> = mol.graph.neighbors(node).map(|n| n.index()).collect();
228
229 if neighbors.len() != 4 {
231 continue;
232 }
233
234 let trees: Vec<CipNode> = neighbors
236 .iter()
237 .map(|&n| build_cip_tree(mol, n, node.index(), 6))
238 .collect();
239
240 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
259fn 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 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
280fn 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 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 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 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; }
315
316 Some(if signed_volume > 0.0 {
317 "R".to_string()
318 } else {
319 "S".to_string()
320 })
321}
322
323fn 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 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 if a_neighbors.is_empty() || b_neighbors.is_empty() {
353 continue;
354 }
355
356 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; }
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
383fn 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 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 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()) } else {
441 Some("E".to_string()) }
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
457fn 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
487pub fn analyze_stereo(mol: &Molecule, positions: &[[f64; 3]]) -> StereoAnalysis {
492 let center_indices = find_stereocenters(mol);
494 let stereocenters: Vec<Stereocenter> = center_indices
495 .iter()
496 .map(|¢er| {
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 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 let atropisomeric_axes = find_atropisomeric_axes(mol, positions);
526
527 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
544fn 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 for node in mol.graph.node_indices() {
558 let atom = &mol.graph[node];
559 if !matches!(atom.element, 6 | 7 | 16) {
561 continue;
562 }
563
564 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 let a = double_neighbors[0];
582 let center = node.index();
583 let b = double_neighbors[1];
584
585 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 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 if let Some(planarity_dev) = measure_planarity(&aromatic_atoms, positions) {
650 if planarity_dev > 0.3 {
651 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 for node in mol.graph.node_indices() {
674 let atom = &mol.graph[node];
675 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 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 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
724fn 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 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 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
754fn 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
782fn 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 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
810fn 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 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 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 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 let ortho_count = a_neighbors.len() + b_neighbors.len();
857 if ortho_count < 3 {
858 continue;
859 }
860
861 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 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
906fn 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 if neighbors.len() != 4 {
920 continue;
921 }
922
923 let trees: Vec<CipNode> = neighbors
925 .iter()
926 .map(|&n| build_cip_tree(mol, n, node.index(), 6))
927 .collect();
928
929 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 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; }
948 }
949 }
950 }
951
952 centers
953}
954
955pub fn stereo_descriptors(analysis: &StereoAnalysis) -> StereoDescriptors {
963 let mut center_descriptors = Vec::new();
964 let mut bond_descriptors = Vec::new();
965
966 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
1008pub struct StereoDescriptors {
1009 pub centers: Vec<StereoAtomDescriptor>,
1011 pub bonds: Vec<StereoBondDescriptor>,
1013}
1014
1015#[derive(Debug, Clone, Serialize, Deserialize)]
1017pub struct StereoAtomDescriptor {
1018 pub atom_index: usize,
1020 pub descriptor: String,
1022 pub configuration: String,
1024}
1025
1026#[derive(Debug, Clone, Serialize, Deserialize)]
1028pub struct StereoBondDescriptor {
1029 pub atom1: usize,
1031 pub atom2: usize,
1033 pub descriptor_atom1: String,
1035 pub descriptor_atom2: String,
1037 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 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 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 let elements: Vec<u8> = sc
1072 .substituent_indices
1073 .iter()
1074 .map(|&i| mol.graph[NodeIndex::new(i)].element)
1075 .collect();
1076 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 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 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}