1#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
33pub enum AromaticityAlgorithm {
34 #[default]
36 Huckel,
37 RdkitLike,
44}
45
46use rustc_hash::{FxHashMap, FxHashSet};
47
48use chematic_core::{AtomIdx, BondIdx, BondOrder, Molecule, implicit_hcount};
49
50use crate::sssr::find_sssr;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum RingAromaticity {
59 Aromatic,
61 Antiaromatic,
63 NonAromatic,
65}
66
67#[derive(Debug, Clone)]
73pub struct AromaticityModel {
74 aromatic_atoms: FxHashSet<AtomIdx>,
75 aromatic_bonds: FxHashSet<BondIdx>,
76 antiaromatic_rings: Vec<Vec<AtomIdx>>,
77 ring_classifications: Vec<(Vec<AtomIdx>, RingAromaticity, u32)>,
78}
79
80impl AromaticityModel {
81 pub fn is_atom_aromatic(&self, idx: AtomIdx) -> bool {
83 self.aromatic_atoms.contains(&idx)
84 }
85
86 pub fn is_bond_aromatic(&self, idx: BondIdx) -> bool {
88 self.aromatic_bonds.contains(&idx)
89 }
90
91 pub fn aromatic_atom_count(&self) -> usize {
93 self.aromatic_atoms.len()
94 }
95
96 pub fn ring_classifications(&self) -> &[(Vec<AtomIdx>, RingAromaticity, u32)] {
101 &self.ring_classifications
102 }
103
104 pub fn antiaromatic_rings(&self) -> &[Vec<AtomIdx>] {
106 &self.antiaromatic_rings
107 }
108
109 pub fn has_antiaromaticity(&self) -> bool {
111 !self.antiaromatic_rings.is_empty()
112 }
113}
114
115#[allow(clippy::manual_is_multiple_of)]
121fn classify_ring_aromaticity(pi_electrons: u32) -> (RingAromaticity, u32) {
122 if pi_electrons >= 2 && (pi_electrons - 2) % 4 == 0 {
123 (RingAromaticity::Aromatic, pi_electrons)
124 } else if pi_electrons > 0 && pi_electrons % 4 == 0 {
125 (RingAromaticity::Antiaromatic, pi_electrons)
126 } else {
127 (RingAromaticity::NonAromatic, pi_electrons)
128 }
129}
130
131fn mark_ring_aromatic(
133 mol: &Molecule,
134 ring: &[AtomIdx],
135 aromatic_atoms: &mut FxHashSet<AtomIdx>,
136 aromatic_bonds: &mut FxHashSet<BondIdx>,
137) {
138 for &atom in ring {
139 aromatic_atoms.insert(atom);
140 }
141 for i in 0..ring.len() {
142 let a = ring[i];
143 let b = ring[(i + 1) % ring.len()];
144 if let Some((bidx, _)) = mol.bond_between(a, b) {
145 aromatic_bonds.insert(bidx);
146 }
147 }
148}
149
150pub fn assign_aromaticity(mol: &Molecule) -> AromaticityModel {
164 assign_aromaticity_ex(mol, AromaticityAlgorithm::Huckel)
165}
166
167pub fn assign_aromaticity_ex(mol: &Molecule, algo: AromaticityAlgorithm) -> AromaticityModel {
173 let ring_set = find_sssr(mol);
174 let sssr_rings = ring_set.rings();
175
176 let rings: Vec<Vec<AtomIdx>> = augmented_ring_set(mol, sssr_rings);
180
181 let mut aromatic_atoms: FxHashSet<AtomIdx> = FxHashSet::default();
182 let mut aromatic_bonds: FxHashSet<BondIdx> = FxHashSet::default();
183 let mut antiaromatic_rings: Vec<Vec<AtomIdx>> = Vec::new();
184
185 let mut classifications: Vec<Option<(RingAromaticity, u32)>> = vec![None; rings.len()];
187
188 let mut pass2_candidates: Vec<usize> = Vec::new();
191
192 let empty_context = FxHashSet::default();
194 for (ring_idx, ring) in rings.iter().enumerate() {
195 match ring_pi_electrons(mol, ring, &empty_context, algo) {
196 Some(pi) => {
197 let (cls, count) = classify_ring_aromaticity(pi);
198 classifications[ring_idx] = Some((cls, count));
199 match cls {
200 RingAromaticity::Aromatic => {
201 mark_ring_aromatic(mol, ring, &mut aromatic_atoms, &mut aromatic_bonds);
202 }
203 RingAromaticity::Antiaromatic => {
204 antiaromatic_rings.push(ring.to_vec());
205 }
207 RingAromaticity::NonAromatic => {
208 pass2_candidates.push(ring_idx);
209 }
210 }
211 }
212 None => {
213 pass2_candidates.push(ring_idx);
215 }
216 }
217 }
218
219 loop {
223 let mut any_new = false;
224 let mut still_pending: Vec<usize> = Vec::new();
225
226 for ring_idx in pass2_candidates {
227 let ring = &rings[ring_idx];
228 if !ring.iter().any(|a| aromatic_atoms.contains(a)) {
230 still_pending.push(ring_idx);
231 continue;
232 }
233 match ring_pi_electrons(mol, ring, &aromatic_atoms, algo) {
234 Some(pi) => {
235 let (cls, count) = classify_ring_aromaticity(pi);
236 classifications[ring_idx] = Some((cls, count));
237 if matches!(cls, RingAromaticity::Aromatic) {
238 mark_ring_aromatic(mol, ring, &mut aromatic_atoms, &mut aromatic_bonds);
239 any_new = true;
240 }
241 }
243 None => {
244 still_pending.push(ring_idx);
245 }
246 }
247 }
248
249 pass2_candidates = still_pending;
250 if !any_new {
251 break;
252 }
253 }
254
255 let ring_classifications: Vec<(Vec<AtomIdx>, RingAromaticity, u32)> = rings
257 .iter()
258 .take(sssr_rings.len()) .enumerate()
260 .filter_map(|(i, ring)| classifications[i].map(|(cls, count)| (ring.to_vec(), cls, count)))
261 .collect();
262
263 AromaticityModel {
264 aromatic_atoms,
265 aromatic_bonds,
266 antiaromatic_rings,
267 ring_classifications,
268 }
269}
270
271pub fn apply_aromaticity(mol: &Molecule) -> Molecule {
283 apply_aromaticity_ex(mol, AromaticityAlgorithm::Huckel)
284}
285
286pub fn apply_aromaticity_ex(mol: &Molecule, algo: AromaticityAlgorithm) -> Molecule {
290 use chematic_core::{BondOrder, MoleculeBuilder};
291
292 let model = assign_aromaticity_ex(mol, algo);
293 let mut builder = MoleculeBuilder::new();
294
295 for (idx, atom) in mol.atoms() {
296 let mut a = atom.clone();
297 if model.is_atom_aromatic(idx) {
298 a.aromatic = true;
299 }
300 builder.add_atom(a);
301 }
302 for (bidx, bond) in mol.bonds() {
303 let order = if model.is_bond_aromatic(bidx) {
304 BondOrder::Aromatic
305 } else {
306 bond.order
307 };
308 if let Ok(new_bidx) = builder.add_bond(bond.atom1, bond.atom2, order)
309 && order == BondOrder::Aromatic
310 && matches!(bond.order, BondOrder::Up | BondOrder::Down)
311 {
312 builder.set_bond_direction(new_bidx, bond.order);
317 }
318 }
319 builder.copy_stereo_groups_from(mol);
324 builder.copy_stereo_from(mol);
325 builder.copy_bond_directions_from(mol);
326 builder.build()
327}
328
329fn ring_bond_set(mol: &Molecule, ring: &[AtomIdx]) -> Vec<BondIdx> {
335 let n = ring.len();
336 let mut bonds: Vec<BondIdx> = (0..n)
337 .filter_map(|i| {
338 let a = ring[i];
339 let b = ring[(i + 1) % n];
340 mol.bond_between(a, b).map(|(bidx, _)| bidx)
341 })
342 .collect();
343 bonds.sort();
344 bonds
345}
346
347fn bond_sym_diff(a: &[BondIdx], b: &[BondIdx]) -> Vec<BondIdx> {
349 let mut result: Vec<BondIdx> = Vec::new();
350 let mut i = 0;
351 let mut j = 0;
352 while i < a.len() && j < b.len() {
353 match a[i].cmp(&b[j]) {
354 std::cmp::Ordering::Less => {
355 result.push(a[i]);
356 i += 1;
357 }
358 std::cmp::Ordering::Greater => {
359 result.push(b[j]);
360 j += 1;
361 }
362 std::cmp::Ordering::Equal => {
363 i += 1;
364 j += 1;
365 }
366 }
367 }
368 result.extend_from_slice(&a[i..]);
369 result.extend_from_slice(&b[j..]);
370 result
371}
372
373fn ring_atoms_from_bond_set(mol: &Molecule, bonds: &[BondIdx]) -> Option<Vec<AtomIdx>> {
376 if bonds.is_empty() {
377 return None;
378 }
379 let mut adj: FxHashMap<AtomIdx, [Option<AtomIdx>; 2]> = FxHashMap::default();
380 for &bidx in bonds {
381 let bond = mol.bond(bidx);
382 for (a, b) in [(bond.atom1, bond.atom2), (bond.atom2, bond.atom1)] {
383 let e = adj.entry(a).or_insert([None; 2]);
384 if e[0].is_none() {
385 e[0] = Some(b);
386 } else if e[1].is_none() {
387 e[1] = Some(b);
388 } else {
389 return None; }
391 }
392 }
393 if adj.values().any(|e| e[1].is_none()) {
395 return None;
396 }
397 let start = *adj.keys().next()?;
398 let mut path = vec![start];
399 let mut prev = start;
400 let mut current = adj[&start][0]?;
401 while current != start {
402 path.push(current);
403 let [n0, n1] = adj[¤t];
404 let next = if n0 == Some(prev) { n1? } else { n0? };
405 prev = current;
406 current = next;
407 }
408 if path.len() != bonds.len() {
409 return None;
410 }
411 Some(path)
412}
413
414pub fn augmented_ring_set(mol: &Molecule, sssr_rings: &[Vec<AtomIdx>]) -> Vec<Vec<AtomIdx>> {
429 let mut rings: Vec<Vec<AtomIdx>> = sssr_rings.to_vec();
430
431 let mut known: FxHashSet<Vec<AtomIdx>> = sssr_rings
433 .iter()
434 .map(|r| {
435 let mut s = r.clone();
436 s.sort();
437 s
438 })
439 .collect();
440
441 loop {
450 let mut changed = false;
451 let n = rings.len();
452 let bond_sets: Vec<Vec<BondIdx>> = rings.iter().map(|r| ring_bond_set(mol, r)).collect();
453
454 for i in 0..n {
455 for j in (i + 1)..n {
456 let shares_atom = rings[i].iter().any(|a| rings[j].contains(a));
458 if !shares_atom {
459 continue;
460 }
461 let xor_bonds = bond_sym_diff(&bond_sets[i], &bond_sets[j]);
462 if xor_bonds.is_empty() {
463 continue;
464 }
465 if xor_bonds.len() > rings[i].len().max(rings[j].len()) {
475 continue;
476 }
477 if let Some(new_ring) = ring_atoms_from_bond_set(mol, &xor_bonds) {
478 let mut key = new_ring.clone();
479 key.sort();
480 if known.insert(key) {
481 rings.push(new_ring);
482 changed = true;
483 }
484 }
485 }
486 }
487
488 for i in 0..n {
491 for j in (i + 1)..n {
492 let shares_ij = rings[i].iter().any(|a| rings[j].contains(a));
493 if !shares_ij {
494 continue;
495 }
496 let xor_ij = bond_sym_diff(&bond_sets[i], &bond_sets[j]);
497 if xor_ij.is_empty() {
498 continue;
499 }
500 for k in (j + 1)..n {
501 let shares_k = rings[k]
502 .iter()
503 .any(|a| rings[i].contains(a) || rings[j].contains(a));
504 if !shares_k {
505 continue;
506 }
507 let xor_ijk = bond_sym_diff(&xor_ij, &bond_sets[k]);
508 let max_size = rings[i].len().max(rings[j].len()).max(rings[k].len());
509 if xor_ijk.is_empty() || xor_ijk.len() > max_size {
510 continue;
511 }
512 if let Some(new_ring) = ring_atoms_from_bond_set(mol, &xor_ijk) {
513 let mut key = new_ring.clone();
514 key.sort();
515 if known.insert(key) {
516 rings.push(new_ring);
517 changed = true;
518 }
519 }
520 }
521 }
522 }
523
524 if !changed {
525 break;
526 }
527 }
528
529 rings
530}
531
532fn all_ring_list_inner(mol: &Molecule) -> Vec<Vec<AtomIdx>> {
534 let sssr = crate::sssr::find_sssr(mol);
535 let aug = augmented_ring_set(mol, sssr.rings());
536 if aug.len() <= 1 {
537 return aug;
538 }
539 let bond_sets: Vec<Vec<BondIdx>> = aug.iter().map(|r| ring_bond_set(mol, r)).collect();
540 let mut is_envelope = vec![false; aug.len()];
541 strip_envelope_rings(&aug, &bond_sets, &mut is_envelope);
542 aug.into_iter()
543 .zip(is_envelope)
544 .filter(|(_, e)| !e)
545 .map(|(r, _)| r)
546 .collect()
547}
548
549pub fn all_ring_list(mol: &Molecule) -> Vec<Vec<AtomIdx>> {
555 all_ring_list_inner(mol)
556}
557
558pub fn ring_bonds_all_aromatic(mol: &Molecule, ring: &[AtomIdx]) -> bool {
566 let n = ring.len();
567 (0..n).all(|i| {
568 let a = ring[i];
569 let b = ring[(i + 1) % n];
570 mol.bond_between(a, b)
571 .map(|(bidx, _)| mol.bond(bidx).order == BondOrder::Aromatic)
572 .unwrap_or(true)
573 })
574}
575
576pub fn aromatic_ring_list(mol: &Molecule) -> Vec<Vec<AtomIdx>> {
579 let mol_with_arom;
580 let mol = if mol.atoms().any(|(_, a)| a.aromatic) {
581 mol
582 } else {
583 mol_with_arom = apply_aromaticity(mol);
584 &mol_with_arom
585 };
586 all_ring_list_inner(mol)
587 .into_iter()
588 .filter(|ring| {
589 ring.iter().all(|&idx| mol.atom(idx).aromatic) && ring_bonds_all_aromatic(mol, ring)
590 })
591 .collect()
592}
593
594fn strip_envelope_rings(
596 aromatic: &[Vec<AtomIdx>],
597 bond_sets: &[Vec<BondIdx>],
598 is_envelope: &mut [bool],
599) {
600 let n = aromatic.len();
601 for i in 0..n {
602 let si = aromatic[i].len();
603 'jk: for j in 0..n {
604 if j == i || aromatic[j].len() >= si {
605 continue;
606 }
607 for k in (j + 1)..n {
608 if k == i || aromatic[k].len() >= si {
609 continue;
610 }
611 if bond_sym_diff(&bond_sets[j], &bond_sets[k]) == bond_sets[i] {
612 is_envelope[i] = true;
613 break 'jk;
614 }
615 }
616 }
617 if !is_envelope[i] {
618 'jkl: for j in 0..n {
619 if j == i || aromatic[j].len() >= si {
620 continue;
621 }
622 for k in (j + 1)..n {
623 if k == i || aromatic[k].len() >= si {
624 continue;
625 }
626 let xor_jk = bond_sym_diff(&bond_sets[j], &bond_sets[k]);
627 for l in (k + 1)..n {
628 if l == i || aromatic[l].len() >= si {
629 continue;
630 }
631 if bond_sym_diff(&xor_jk, &bond_sets[l]) == bond_sets[i] {
632 is_envelope[i] = true;
633 break 'jkl;
634 }
635 }
636 }
637 }
638 }
639 if !is_envelope[i] {
640 'jklm: for j in 0..n {
641 if j == i || aromatic[j].len() >= si {
642 continue;
643 }
644 for k in (j + 1)..n {
645 if k == i || aromatic[k].len() >= si {
646 continue;
647 }
648 let xor_jk = bond_sym_diff(&bond_sets[j], &bond_sets[k]);
649 for l in (k + 1)..n {
650 if l == i || aromatic[l].len() >= si {
651 continue;
652 }
653 let xor_jkl = bond_sym_diff(&xor_jk, &bond_sets[l]);
654 for m in (l + 1)..n {
655 if m == i || aromatic[m].len() >= si {
656 continue;
657 }
658 if bond_sym_diff(&xor_jkl, &bond_sets[m]) == bond_sets[i] {
659 is_envelope[i] = true;
660 break 'jklm;
661 }
662 }
663 }
664 }
665 }
666 }
667 }
668}
669
670pub fn count_aromatic_rings(mol: &Molecule) -> usize {
671 let mol_with_arom;
674 let mol = if mol.atoms().any(|(_, a)| a.aromatic) {
675 mol } else {
677 mol_with_arom = apply_aromaticity(mol);
678 &mol_with_arom
679 };
680
681 let sssr = crate::sssr::find_sssr(mol);
682 let aug = augmented_ring_set(mol, sssr.rings());
683
684 let aromatic: Vec<Vec<AtomIdx>> = aug
686 .into_iter()
687 .filter(|ring| ring.iter().all(|&idx| mol.atom(idx).aromatic))
688 .collect();
689
690 if aromatic.len() <= 1 {
691 return aromatic.len();
692 }
693
694 let bond_sets: Vec<Vec<BondIdx>> = aromatic.iter().map(|r| ring_bond_set(mol, r)).collect();
696
697 let n = aromatic.len();
706 let mut is_envelope = vec![false; n];
707 strip_envelope_rings(&aromatic, &bond_sets, &mut is_envelope);
708 is_envelope.iter().filter(|&&e| !e).count()
709}
710
711fn ring_pi_electrons(
737 mol: &Molecule,
738 ring: &[AtomIdx],
739 aromatic_context: &FxHashSet<AtomIdx>,
740 algo: AromaticityAlgorithm,
741) -> Option<u32> {
742 let ring_atom_set: FxHashSet<AtomIdx> = ring.iter().copied().collect();
743 let mut total_pi: u32 = 0;
744
745 for &atom_idx in ring {
746 if aromatic_context.contains(&atom_idx) {
748 total_pi += 1;
749 continue;
750 }
751
752 let atom = mol.atom(atom_idx);
753 let an = atom.element.atomic_number();
754
755 let ring_degree = mol
756 .neighbors(atom_idx)
757 .filter(|(nb, _)| ring_atom_set.contains(nb))
758 .count();
759
760 let total_degree = mol.degree(atom_idx);
761
762 let has_explicit_double = mol
764 .neighbors(atom_idx)
765 .any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Double);
766
767 let has_double_any = has_explicit_double
769 || mol
770 .neighbors(atom_idx)
771 .any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Aromatic);
772
773 let has_aromatic_in_ring = mol
775 .neighbors(atom_idx)
776 .filter(|(nb, _)| ring_atom_set.contains(nb))
777 .any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Aromatic);
778
779 let pi = match an {
780 6 => {
782 if !has_double_any {
783 return None; }
785 1
786 }
787
788 7 => {
790 if implicit_hcount(mol, atom_idx) > 0 {
791 2
793 } else if has_explicit_double {
794 1
796 } else if total_degree == 3 && ring_degree < total_degree {
797 let has_sp2_exocyclic = mol
806 .neighbors(atom_idx)
807 .filter(|(nb, _)| !ring_atom_set.contains(nb))
808 .any(|(nb, _)| {
809 mol.neighbors(nb).any(|(_, b2)| {
810 matches!(
811 mol.bond(b2).order,
812 BondOrder::Double | BondOrder::Aromatic
813 )
814 })
815 });
816 if has_sp2_exocyclic {
817 2
818 } else {
819 return None;
820 }
821 } else if has_aromatic_in_ring {
822 1
825 } else {
826 return None;
828 }
829 }
830
831 8 | 16 => {
833 if ring_degree != 2 {
834 return None;
835 }
836 if an == 16
838 && mol.neighbors(atom_idx).any(|(nb, bidx)| {
839 !ring_atom_set.contains(&nb) && mol.bond(bidx).order == BondOrder::Double
840 })
841 {
842 return None;
843 }
844 2
845 }
846
847 34 | 52 => {
850 if algo != AromaticityAlgorithm::RdkitLike {
851 return None;
852 }
853 if ring_degree != 2 {
854 return None;
855 }
856 if mol.neighbors(atom_idx).any(|(nb, bidx)| {
858 !ring_atom_set.contains(&nb) && mol.bond(bidx).order == BondOrder::Double
859 }) {
860 return None;
861 }
862 2
863 }
864
865 _ => return None,
867 };
868
869 total_pi += pi;
870 }
871
872 Some(total_pi)
873}
874
875#[cfg(test)]
880mod tests {
881 use super::*;
882 use chematic_core::{Atom, BondOrder, Element, MoleculeBuilder};
883
884 fn benzene_kekule() -> chematic_core::Molecule {
889 let mut b = MoleculeBuilder::new();
890 let atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
891 for i in 0..6 {
892 let order = if i % 2 == 0 {
893 BondOrder::Double
894 } else {
895 BondOrder::Single
896 };
897 b.add_bond(atoms[i], atoms[(i + 1) % 6], order).unwrap();
898 }
899 b.build()
900 }
901
902 fn cyclohexane() -> chematic_core::Molecule {
903 let mut b = MoleculeBuilder::new();
904 let atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
905 for i in 0..6 {
906 b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Single)
907 .unwrap();
908 }
909 b.build()
910 }
911
912 fn pyridine_kekule() -> chematic_core::Molecule {
913 let mut b = MoleculeBuilder::new();
914 let n = b.add_atom(Atom::new(Element::N));
915 let atoms_c: Vec<_> = (0..5).map(|_| b.add_atom(Atom::new(Element::C))).collect();
916 let ring = [
917 n, atoms_c[0], atoms_c[1], atoms_c[2], atoms_c[3], atoms_c[4],
918 ];
919 for i in 0..6 {
920 let order = if i % 2 == 0 {
921 BondOrder::Double
922 } else {
923 BondOrder::Single
924 };
925 b.add_bond(ring[i], ring[(i + 1) % 6], order).unwrap();
926 }
927 b.build()
928 }
929
930 fn furan_kekule() -> chematic_core::Molecule {
931 let mut b = MoleculeBuilder::new();
932 let o = b.add_atom(Atom::new(Element::O));
933 let c1 = b.add_atom(Atom::new(Element::C));
934 let c2 = b.add_atom(Atom::new(Element::C));
935 let c3 = b.add_atom(Atom::new(Element::C));
936 let c4 = b.add_atom(Atom::new(Element::C));
937 let ring = [o, c1, c2, c3, c4];
938 b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
939 b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
940 b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
941 b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
942 b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
943 b.build()
944 }
945
946 fn pyrrole_kekule() -> chematic_core::Molecule {
947 let mut b = MoleculeBuilder::new();
948 let mut n_atom = Atom::new(Element::N);
949 n_atom.hydrogen_count = Some(1);
950 let n = b.add_atom(n_atom);
951 let c1 = b.add_atom(Atom::new(Element::C));
952 let c2 = b.add_atom(Atom::new(Element::C));
953 let c3 = b.add_atom(Atom::new(Element::C));
954 let c4 = b.add_atom(Atom::new(Element::C));
955 let ring = [n, c1, c2, c3, c4];
956 b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
957 b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
958 b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
959 b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
960 b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
961 b.build()
962 }
963
964 fn naphthalene_kekule() -> chematic_core::Molecule {
965 let mut b = MoleculeBuilder::new();
966 let atoms: Vec<_> = (0..10).map(|_| b.add_atom(Atom::new(Element::C))).collect();
967 let ring1 = [0usize, 1, 2, 3, 4, 9];
968 let orders1 = [
969 BondOrder::Double,
970 BondOrder::Single,
971 BondOrder::Double,
972 BondOrder::Single,
973 BondOrder::Double,
974 BondOrder::Single,
975 ];
976 for i in 0..6 {
977 b.add_bond(atoms[ring1[i]], atoms[ring1[(i + 1) % 6]], orders1[i])
978 .unwrap();
979 }
980 let ring2_extra = [(4, 5), (5, 6), (6, 7), (7, 8), (8, 9)];
981 let orders2 = [
982 BondOrder::Single,
983 BondOrder::Double,
984 BondOrder::Single,
985 BondOrder::Double,
986 BondOrder::Single,
987 ];
988 for (i, &(a, bb)) in ring2_extra.iter().enumerate() {
989 b.add_bond(atoms[a], atoms[bb], orders2[i]).unwrap();
990 }
991 b.build()
992 }
993
994 fn cyclobutadiene_kekule() -> chematic_core::Molecule {
995 let mut b = MoleculeBuilder::new();
996 let atoms: Vec<_> = (0..4).map(|_| b.add_atom(Atom::new(Element::C))).collect();
997 for i in 0..4 {
998 let order = if i % 2 == 0 {
999 BondOrder::Double
1000 } else {
1001 BondOrder::Single
1002 };
1003 b.add_bond(atoms[i], atoms[(i + 1) % 4], order).unwrap();
1004 }
1005 b.build()
1006 }
1007
1008 fn cyclooctatetraene_kekule() -> chematic_core::Molecule {
1009 let mut b = MoleculeBuilder::new();
1010 let atoms: Vec<_> = (0..8).map(|_| b.add_atom(Atom::new(Element::C))).collect();
1011 for i in 0..8 {
1012 let order = if i % 2 == 0 {
1013 BondOrder::Double
1014 } else {
1015 BondOrder::Single
1016 };
1017 b.add_bond(atoms[i], atoms[(i + 1) % 8], order).unwrap();
1018 }
1019 b.build()
1020 }
1021
1022 #[cfg(test)]
1025 fn mol_aromatic(smiles: &str) -> chematic_core::Molecule {
1026 chematic_smiles::parse(smiles).expect("valid SMILES")
1027 }
1028
1029 #[cfg(test)]
1031 fn mol_kekulized(smiles: &str) -> chematic_core::Molecule {
1032 let mol = chematic_smiles::parse(smiles).expect("valid SMILES");
1033 let k = chematic_core::kekulize(&mol).expect("kekulizable");
1034 chematic_core::apply_kekule(&mol, &k)
1035 }
1036
1037 #[test]
1042 fn test_benzene_is_aromatic() {
1043 let mol = benzene_kekule();
1044 let model = assign_aromaticity(&mol);
1045 assert_eq!(
1046 model.aromatic_atom_count(),
1047 6,
1048 "all 6 benzene atoms aromatic"
1049 );
1050 for i in 0..6u32 {
1051 assert!(model.is_atom_aromatic(AtomIdx(i)));
1052 }
1053 }
1054
1055 #[test]
1056 fn test_cyclohexane_not_aromatic() {
1057 let mol = cyclohexane();
1058 let model = assign_aromaticity(&mol);
1059 assert_eq!(model.aromatic_atom_count(), 0, "cyclohexane not aromatic");
1060 }
1061
1062 #[test]
1063 fn test_pyridine_is_aromatic() {
1064 let mol = pyridine_kekule();
1065 let model = assign_aromaticity(&mol);
1066 assert_eq!(model.aromatic_atom_count(), 6);
1067 }
1068
1069 #[test]
1070 fn test_furan_is_aromatic() {
1071 let mol = furan_kekule();
1072 let model = assign_aromaticity(&mol);
1073 assert_eq!(model.aromatic_atom_count(), 5);
1074 }
1075
1076 #[test]
1077 fn test_pyrrole_is_aromatic() {
1078 let mol = pyrrole_kekule();
1079 let model = assign_aromaticity(&mol);
1080 assert_eq!(model.aromatic_atom_count(), 5);
1081 }
1082
1083 #[test]
1084 fn test_naphthalene_both_rings_aromatic() {
1085 let mol = naphthalene_kekule();
1086 let model = assign_aromaticity(&mol);
1087 assert_eq!(
1088 model.aromatic_atom_count(),
1089 10,
1090 "all 10 naphthalene atoms aromatic"
1091 );
1092 }
1093
1094 #[test]
1095 fn test_bond_aromaticity_benzene() {
1096 let mol = benzene_kekule();
1097 let model = assign_aromaticity(&mol);
1098 let count = mol
1099 .bonds()
1100 .filter(|(b, _)| model.is_bond_aromatic(*b))
1101 .count();
1102 assert_eq!(count, 6);
1103 }
1104
1105 #[test]
1106 fn test_apply_aromaticity_benzene() {
1107 let mol = benzene_kekule();
1108 let aromatic = apply_aromaticity(&mol);
1109 for (_, atom) in aromatic.atoms() {
1110 assert!(atom.aromatic, "every benzene carbon should be aromatic");
1111 }
1112 let aromatic_bond_count = aromatic
1113 .bonds()
1114 .filter(|(_, b)| b.order == BondOrder::Aromatic)
1115 .count();
1116 assert_eq!(aromatic_bond_count, 6);
1117 }
1118
1119 #[test]
1120 fn test_apply_aromaticity_cyclohexane_unchanged() {
1121 let mol = cyclohexane();
1122 let result = apply_aromaticity(&mol);
1123 for (_, atom) in result.atoms() {
1124 assert!(!atom.aromatic);
1125 }
1126 for (_, bond) in result.bonds() {
1127 assert_ne!(bond.order, BondOrder::Aromatic);
1128 }
1129 }
1130
1131 #[test]
1136 fn test_cyclobutadiene_antiaromatic() {
1137 let mol = cyclobutadiene_kekule();
1138 let model = assign_aromaticity(&mol);
1139 assert_eq!(
1140 model.aromatic_atom_count(),
1141 0,
1142 "cyclobutadiene not aromatic"
1143 );
1144 assert!(model.has_antiaromaticity(), "cyclobutadiene antiaromatic");
1145 assert_eq!(model.antiaromatic_rings().len(), 1);
1146 let classifications = model.ring_classifications();
1147 assert_eq!(classifications.len(), 1);
1148 assert_eq!(classifications[0].1, RingAromaticity::Antiaromatic);
1149 assert_eq!(classifications[0].2, 4);
1150 }
1151
1152 #[test]
1153 fn test_cyclooctatetraene_antiaromatic() {
1154 let mol = cyclooctatetraene_kekule();
1155 let model = assign_aromaticity(&mol);
1156 assert_eq!(model.aromatic_atom_count(), 0, "COT not aromatic");
1157 assert!(model.has_antiaromaticity(), "COT antiaromatic");
1158 assert_eq!(model.antiaromatic_rings().len(), 1);
1159 let cls = &model.ring_classifications()[0];
1160 assert_eq!(cls.1, RingAromaticity::Antiaromatic);
1161 assert_eq!(cls.2, 8);
1162 }
1163
1164 #[test]
1169 fn test_ring_classifications_benzene() {
1170 let mol = benzene_kekule();
1171 let model = assign_aromaticity(&mol);
1172 let classifications = model.ring_classifications();
1173 assert_eq!(classifications.len(), 1);
1174 assert_eq!(classifications[0].1, RingAromaticity::Aromatic);
1175 assert_eq!(classifications[0].2, 6);
1176 }
1177
1178 #[test]
1179 fn test_ring_classifications_naphthalene() {
1180 let mol = naphthalene_kekule();
1181 let model = assign_aromaticity(&mol);
1182 let classifications = model.ring_classifications();
1183 assert_eq!(classifications.len(), 2, "naphthalene has two rings");
1184 for (_, classification, count) in classifications {
1185 assert_eq!(*classification, RingAromaticity::Aromatic);
1186 assert_eq!(*count, 6);
1187 }
1188 }
1189
1190 #[test]
1191 fn test_non_aromatic_cyclohexane() {
1192 let mol = cyclohexane();
1193 let model = assign_aromaticity(&mol);
1194 for (_, classification, _) in model.ring_classifications() {
1195 assert_ne!(*classification, RingAromaticity::Aromatic);
1196 assert_ne!(*classification, RingAromaticity::Antiaromatic);
1197 }
1198 }
1199
1200 #[test]
1205 fn test_thiophene_aromatic() {
1206 let mut b = MoleculeBuilder::new();
1207 let s = b.add_atom(Atom::new(Element::S));
1208 let c1 = b.add_atom(Atom::new(Element::C));
1209 let c2 = b.add_atom(Atom::new(Element::C));
1210 let c3 = b.add_atom(Atom::new(Element::C));
1211 let c4 = b.add_atom(Atom::new(Element::C));
1212 let ring = [s, c1, c2, c3, c4];
1213 b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
1214 b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
1215 b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
1216 b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
1217 b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
1218 let mol = b.build();
1219 let model = assign_aromaticity(&mol);
1220 assert_eq!(model.aromatic_atom_count(), 5);
1221 assert_eq!(model.ring_classifications()[0].2, 6);
1222 }
1223
1224 #[test]
1225 fn test_electron_distribution_tracking() {
1226 let mol = benzene_kekule();
1227 let model = assign_aromaticity(&mol);
1228 assert_eq!(model.ring_classifications()[0].2, 6, "benzene: 6 × 1π = 6");
1229
1230 let mol = pyrrole_kekule();
1231 let model = assign_aromaticity(&mol);
1232 assert_eq!(
1233 model.ring_classifications()[0].2,
1234 6,
1235 "pyrrole: N(2π) + 4C(1π) = 6"
1236 );
1237
1238 let mol = furan_kekule();
1239 let model = assign_aromaticity(&mol);
1240 assert_eq!(
1241 model.ring_classifications()[0].2,
1242 6,
1243 "furan: O(2π) + 4C(1π) = 6"
1244 );
1245 }
1246
1247 #[test]
1253 fn test_benzene_aromatic_smiles() {
1254 let mol = mol_aromatic("c1ccccc1");
1256 let model = assign_aromaticity(&mol);
1257 assert_eq!(
1258 model.aromatic_atom_count(),
1259 6,
1260 "benzene from aromatic SMILES"
1261 );
1262 }
1263
1264 #[test]
1265 fn test_naphthalene_aromatic_smiles() {
1266 let mol = mol_aromatic("c1ccc2ccccc2c1");
1267 let model = assign_aromaticity(&mol);
1268 assert_eq!(
1269 model.aromatic_atom_count(),
1270 10,
1271 "naphthalene from aromatic SMILES"
1272 );
1273 }
1274
1275 #[test]
1276 fn test_pyridine_aromatic_smiles() {
1277 let mol = mol_aromatic("c1ccncc1");
1278 let model = assign_aromaticity(&mol);
1279 assert_eq!(
1280 model.aromatic_atom_count(),
1281 6,
1282 "pyridine from aromatic SMILES"
1283 );
1284 }
1285
1286 #[test]
1287 fn test_furan_aromatic_smiles() {
1288 let mol = mol_aromatic("c1ccoc1");
1289 let model = assign_aromaticity(&mol);
1290 assert_eq!(model.aromatic_atom_count(), 5, "furan from aromatic SMILES");
1291 }
1292
1293 #[test]
1294 fn test_pyrrole_aromatic_smiles() {
1295 let mol = mol_aromatic("c1cc[nH]c1");
1297 let model = assign_aromaticity(&mol);
1298 assert_eq!(
1299 model.aromatic_atom_count(),
1300 5,
1301 "pyrrole from aromatic SMILES"
1302 );
1303 }
1304
1305 #[test]
1306 fn test_thiophene_aromatic_smiles() {
1307 let mol = mol_aromatic("c1ccsc1");
1308 let model = assign_aromaticity(&mol);
1309 assert_eq!(
1310 model.aromatic_atom_count(),
1311 5,
1312 "thiophene from aromatic SMILES"
1313 );
1314 }
1315
1316 #[test]
1321 fn test_indole_aromatic() {
1322 let mol = mol_kekulized("c1ccc2[nH]ccc2c1");
1324 let model = assign_aromaticity(&mol);
1325 assert_eq!(
1326 model.aromatic_atom_count(),
1327 9,
1328 "all 9 indole atoms aromatic"
1329 );
1330 }
1331
1332 #[test]
1333 fn test_benzimidazole_aromatic() {
1334 let mol = mol_kekulized("c1ccc2[nH]cnc2c1");
1336 let model = assign_aromaticity(&mol);
1337 assert_eq!(model.aromatic_atom_count(), 9, "all 9 benzimidazole atoms");
1338 }
1339
1340 #[test]
1341 fn test_quinoline_aromatic() {
1342 let mol = mol_kekulized("c1ccc2ncccc2c1");
1343 let model = assign_aromaticity(&mol);
1344 assert_eq!(model.aromatic_atom_count(), 10, "all 10 quinoline atoms");
1345 }
1346
1347 #[test]
1348 fn test_acridine_aromatic() {
1349 let mol = mol_kekulized("c1ccc2nc3ccccc3cc2c1");
1351 let model = assign_aromaticity(&mol);
1352 assert_eq!(model.aromatic_atom_count(), 14, "all 14 acridine atoms");
1354 }
1355
1356 #[test]
1361 fn test_indolizine_aromatic() {
1362 let mol = mol_aromatic("c1ccn2cccc2c1");
1370 let model = assign_aromaticity(&mol);
1371 assert_eq!(
1372 model.aromatic_atom_count(),
1373 9,
1374 "all 9 indolizine atoms aromatic"
1375 );
1376 let has_aromatic_ring = model
1378 .ring_classifications()
1379 .iter()
1380 .any(|(_, cls, _)| *cls == RingAromaticity::Aromatic);
1381 assert!(has_aromatic_ring, "at least one SSSR ring aromatic");
1382 }
1383
1384 #[test]
1385 fn test_purine_aromatic() {
1386 let mol = mol_kekulized("c1cnc2[nH]cnc2n1");
1388 let model = assign_aromaticity(&mol);
1389 assert_eq!(
1390 model.aromatic_atom_count(),
1391 9,
1392 "all 9 purine atoms aromatic"
1393 );
1394 }
1395
1396 #[test]
1397 fn test_purine_aromatic_from_aromatic_smiles() {
1398 let mol = mol_aromatic("c1cnc2[nH]cnc2n1");
1399 let model = assign_aromaticity(&mol);
1400 assert_eq!(
1401 model.aromatic_atom_count(),
1402 9,
1403 "purine from aromatic SMILES"
1404 );
1405 }
1406
1407 #[test]
1408 fn test_2_pyridinone_aromatic() {
1409 let mol = mol_aromatic("O=c1ccncc1");
1415 let model = assign_aromaticity(&mol);
1416 assert_eq!(
1417 model.aromatic_atom_count(),
1418 6,
1419 "all 6 ring atoms of 2-pyridinone aromatic"
1420 );
1421 }
1422
1423 #[test]
1424 fn test_quinolone_aromatic() {
1425 let mol = mol_aromatic("O=c1ccc2ncccc2c1");
1427 let model = assign_aromaticity(&mol);
1428 assert_eq!(
1429 model.aromatic_atom_count(),
1430 10,
1431 "all 10 quinolone ring atoms aromatic"
1432 );
1433 assert_eq!(
1434 model.ring_classifications().len(),
1435 2,
1436 "two rings classified"
1437 );
1438 }
1439
1440 #[test]
1441 fn test_indole_aromatic_smiles() {
1442 let mol = mol_aromatic("c1ccc2[nH]ccc2c1");
1443 let model = assign_aromaticity(&mol);
1444 assert_eq!(
1445 model.aromatic_atom_count(),
1446 9,
1447 "indole from aromatic SMILES"
1448 );
1449 }
1450
1451 #[test]
1456 fn test_bridgehead_n_contributes_lone_pair() {
1457 let mol = mol_aromatic("c1ccn2cccc2c1");
1461 let model = assign_aromaticity(&mol);
1462 assert_eq!(model.aromatic_atom_count(), 9);
1464 assert!(
1467 model.is_atom_aromatic(AtomIdx(3)),
1468 "bridgehead N must be aromatic"
1469 );
1470 }
1471
1472 #[test]
1473 fn test_non_bridgehead_n_no_false_positive() {
1474 let mol = mol_aromatic("c1ccncn1");
1478 let model = assign_aromaticity(&mol);
1479 assert_eq!(model.aromatic_atom_count(), 6, "pyrimidine is aromatic");
1480 }
1481
1482 #[test]
1483 fn test_imidazole_aromatic() {
1484 let mol = mol_aromatic("c1cn[nH]c1");
1486 let model = assign_aromaticity(&mol);
1487 assert_eq!(model.aromatic_atom_count(), 5, "imidazole is aromatic");
1488 }
1489
1490 #[test]
1495 fn test_pass2_needed_for_indolizine_6ring() {
1496 let mol = mol_aromatic("c1ccn2cccc2c1");
1501 let model = assign_aromaticity(&mol);
1502 assert_eq!(
1503 model.aromatic_atom_count(),
1504 9,
1505 "all 9 indolizine atoms aromatic"
1506 );
1507 assert!(
1509 model.is_atom_aromatic(AtomIdx(3)),
1510 "bridgehead N is aromatic"
1511 );
1512 let aromatic_count = model
1514 .ring_classifications()
1515 .iter()
1516 .filter(|(_, cls, _)| *cls == RingAromaticity::Aromatic)
1517 .count();
1518 assert!(aromatic_count >= 1, "at least one SSSR ring is aromatic");
1519 }
1520
1521 #[test]
1522 fn test_no_pass2_needed_for_naphthalene() {
1523 let mol = naphthalene_kekule();
1526 let model = assign_aromaticity(&mol);
1527 assert_eq!(model.aromatic_atom_count(), 10);
1528 let classes = model.ring_classifications();
1529 assert_eq!(classes.len(), 2);
1530 for (_, cls, _) in classes {
1531 assert_eq!(*cls, RingAromaticity::Aromatic);
1532 }
1533 }
1534
1535 #[test]
1536 fn test_anthracene_aromatic() {
1537 let mol = mol_kekulized("c1ccc2cc3ccccc3cc2c1");
1539 let model = assign_aromaticity(&mol);
1540 assert_eq!(model.aromatic_atom_count(), 14, "all 14 anthracene atoms");
1541 }
1542
1543 #[test]
1548 fn test_kekulized_path_unaffected_by_aromatic_bond_changes() {
1549 let mol = benzene_kekule();
1552 for (_, bond) in mol.bonds() {
1554 assert_ne!(bond.order, BondOrder::Aromatic, "input must be kekulized");
1555 }
1556 let model = assign_aromaticity(&mol);
1557 assert_eq!(model.aromatic_atom_count(), 6);
1558 let aromatic_bonds = mol
1560 .bonds()
1561 .filter(|(b, _)| model.is_bond_aromatic(*b))
1562 .count();
1563 assert_eq!(aromatic_bonds, 6);
1564 }
1565
1566 #[test]
1567 fn test_keto_pyridinone_not_huckel_aromatic() {
1568 let mol = mol_kekulized("O=C1NC=CC=C1");
1575 let model = assign_aromaticity(&mol);
1576 assert_eq!(
1577 model.aromatic_atom_count(),
1578 0,
1579 "keto pyridinone is not Hückel aromatic (7π ≠ 4n+2)"
1580 );
1581 }
1582
1583 #[test]
1586 fn test_fluorescein_dianion_aromatic() {
1587 let smi = "C1=CC=C(C(=C1)C2=C3C=CC(=O)C=C3OC4=C2C=CC(=C4)[O-])C(=O)[O-]";
1592 let mol = chematic_smiles::parse(smi).expect("fluorescein dianion should parse");
1593 let arc = count_aromatic_rings(&mol);
1596 assert!(
1597 arc >= 2,
1598 "fluorescein dianion: expected ≥2 aromatic rings, got {arc} \
1599 (RDKit #9271: charged aromatics may be misclassified)"
1600 );
1601 }
1602
1603 #[test]
1604 fn test_rhodamine_zwitterion_parses() {
1605 let smi = "CCN(CC)c1ccc2c(-c3ccccc3C(=O)O)c3ccc(=[N+](CC)CC)cc-3oc2c1";
1608 let mol = chematic_smiles::parse(smi).expect("rhodamine zwitterion should parse");
1609 let arc = count_aromatic_rings(&mol);
1610 assert!(arc >= 3, "rhodamine: expected ≥3 aromatic rings, got {arc}");
1611 }
1612
1613 #[test]
1614 fn test_cyclopentadienyl_not_aromatic_kekulized() {
1615 let mut b = MoleculeBuilder::new();
1617 let c0 = b.add_atom(Atom::new(Element::C)); let c1 = b.add_atom(Atom::new(Element::C));
1619 let c2 = b.add_atom(Atom::new(Element::C));
1620 let c3 = b.add_atom(Atom::new(Element::C));
1621 let c4 = b.add_atom(Atom::new(Element::C));
1622 b.add_bond(c0, c1, BondOrder::Single).unwrap();
1623 b.add_bond(c1, c2, BondOrder::Double).unwrap();
1624 b.add_bond(c2, c3, BondOrder::Single).unwrap();
1625 b.add_bond(c3, c4, BondOrder::Double).unwrap();
1626 b.add_bond(c4, c0, BondOrder::Single).unwrap();
1627 let mol = b.build();
1628 let model = assign_aromaticity(&mol);
1629 assert_eq!(
1630 model.aromatic_atom_count(),
1631 0,
1632 "cyclopentadiene not aromatic"
1633 );
1634 }
1635
1636 #[test]
1641 fn test_selenophene_huckel_not_aromatic() {
1642 let mol = mol_aromatic("c1cc[se]c1");
1645 let m = assign_aromaticity(&mol); assert_eq!(
1647 m.aromatic_atom_count(),
1648 0,
1649 "selenophene: Se not aromatic in Hückel mode"
1650 );
1651 }
1652
1653 #[test]
1654 fn test_selenophene_rdkit_aromatic() {
1655 let mol = mol_aromatic("c1cc[se]c1");
1657 let m = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
1658 assert_eq!(
1659 m.aromatic_atom_count(),
1660 5,
1661 "selenophene: all 5 atoms aromatic in RdkitLike"
1662 );
1663 }
1664
1665 #[test]
1666 fn test_tellurophene_rdkit_aromatic() {
1667 let mol = mol_aromatic("c1cc[te]c1");
1669 let m = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
1670 assert_eq!(
1671 m.aromatic_atom_count(),
1672 5,
1673 "tellurophene: all 5 atoms aromatic in RdkitLike"
1674 );
1675 }
1676
1677 #[test]
1678 fn test_benzoselenophene_rdkit() {
1679 let mol = mol_aromatic("c1ccc2[se]ccc2c1");
1681 let m = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
1682 assert_eq!(
1683 m.aromatic_atom_count(),
1684 9,
1685 "benzoselenophene: 9 atoms aromatic"
1686 );
1687 }
1688
1689 #[test]
1690 fn test_rdkit_mode_does_not_break_benzene() {
1691 let mol = mol_aromatic("c1ccccc1");
1693 let m_h = assign_aromaticity(&mol);
1694 let m_r = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
1695 assert_eq!(m_h.aromatic_atom_count(), m_r.aromatic_atom_count());
1696 }
1697
1698 #[test]
1699 fn test_rdkit_mode_does_not_break_thiophene() {
1700 let mol = mol_aromatic("c1ccsc1");
1701 let m_h = assign_aromaticity(&mol);
1702 let m_r = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
1703 assert_eq!(
1704 m_h.aromatic_atom_count(),
1705 m_r.aromatic_atom_count(),
1706 "thiophene same in both modes"
1707 );
1708 }
1709}