1use rustc_hash::{FxHashMap, FxHashSet};
26
27use chematic_core::{AtomIdx, BondIdx, BondOrder, Molecule, implicit_hcount};
28
29use crate::sssr::find_sssr;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum RingAromaticity {
38 Aromatic,
40 Antiaromatic,
42 NonAromatic,
44}
45
46#[derive(Debug, Clone)]
52pub struct AromaticityModel {
53 aromatic_atoms: FxHashSet<AtomIdx>,
54 aromatic_bonds: FxHashSet<BondIdx>,
55 antiaromatic_rings: Vec<Vec<AtomIdx>>,
56 ring_classifications: Vec<(Vec<AtomIdx>, RingAromaticity, u32)>,
57}
58
59impl AromaticityModel {
60 pub fn is_atom_aromatic(&self, idx: AtomIdx) -> bool {
62 self.aromatic_atoms.contains(&idx)
63 }
64
65 pub fn is_bond_aromatic(&self, idx: BondIdx) -> bool {
67 self.aromatic_bonds.contains(&idx)
68 }
69
70 pub fn aromatic_atom_count(&self) -> usize {
72 self.aromatic_atoms.len()
73 }
74
75 pub fn ring_classifications(&self) -> &[(Vec<AtomIdx>, RingAromaticity, u32)] {
80 &self.ring_classifications
81 }
82
83 pub fn antiaromatic_rings(&self) -> &[Vec<AtomIdx>] {
85 &self.antiaromatic_rings
86 }
87
88 pub fn has_antiaromaticity(&self) -> bool {
90 !self.antiaromatic_rings.is_empty()
91 }
92}
93
94#[allow(clippy::manual_is_multiple_of)]
100fn classify_ring_aromaticity(pi_electrons: u32) -> (RingAromaticity, u32) {
101 if pi_electrons >= 2 && (pi_electrons - 2) % 4 == 0 {
102 (RingAromaticity::Aromatic, pi_electrons)
103 } else if pi_electrons > 0 && pi_electrons % 4 == 0 {
104 (RingAromaticity::Antiaromatic, pi_electrons)
105 } else {
106 (RingAromaticity::NonAromatic, pi_electrons)
107 }
108}
109
110fn mark_ring_aromatic(
112 mol: &Molecule,
113 ring: &[AtomIdx],
114 aromatic_atoms: &mut FxHashSet<AtomIdx>,
115 aromatic_bonds: &mut FxHashSet<BondIdx>,
116) {
117 for &atom in ring {
118 aromatic_atoms.insert(atom);
119 }
120 for i in 0..ring.len() {
121 let a = ring[i];
122 let b = ring[(i + 1) % ring.len()];
123 if let Some((bidx, _)) = mol.bond_between(a, b) {
124 aromatic_bonds.insert(bidx);
125 }
126 }
127}
128
129pub fn assign_aromaticity(mol: &Molecule) -> AromaticityModel {
140 let ring_set = find_sssr(mol);
141 let sssr_rings = ring_set.rings();
142
143 let rings: Vec<Vec<AtomIdx>> = augmented_ring_set(mol, sssr_rings);
147
148 let mut aromatic_atoms: FxHashSet<AtomIdx> = FxHashSet::default();
149 let mut aromatic_bonds: FxHashSet<BondIdx> = FxHashSet::default();
150 let mut antiaromatic_rings: Vec<Vec<AtomIdx>> = Vec::new();
151
152 let mut classifications: Vec<Option<(RingAromaticity, u32)>> = vec![None; rings.len()];
154
155 let mut pass2_candidates: Vec<usize> = Vec::new();
158
159 let empty_context = FxHashSet::default();
161 for (ring_idx, ring) in rings.iter().enumerate() {
162 match ring_pi_electrons(mol, ring, &empty_context) {
163 Some(pi) => {
164 let (cls, count) = classify_ring_aromaticity(pi);
165 classifications[ring_idx] = Some((cls, count));
166 match cls {
167 RingAromaticity::Aromatic => {
168 mark_ring_aromatic(mol, ring, &mut aromatic_atoms, &mut aromatic_bonds);
169 }
170 RingAromaticity::Antiaromatic => {
171 antiaromatic_rings.push(ring.to_vec());
172 }
174 RingAromaticity::NonAromatic => {
175 pass2_candidates.push(ring_idx);
176 }
177 }
178 }
179 None => {
180 pass2_candidates.push(ring_idx);
182 }
183 }
184 }
185
186 loop {
190 let mut any_new = false;
191 let mut still_pending: Vec<usize> = Vec::new();
192
193 for ring_idx in pass2_candidates {
194 let ring = &rings[ring_idx];
195 if !ring.iter().any(|a| aromatic_atoms.contains(a)) {
197 still_pending.push(ring_idx);
198 continue;
199 }
200 match ring_pi_electrons(mol, ring, &aromatic_atoms) {
201 Some(pi) => {
202 let (cls, count) = classify_ring_aromaticity(pi);
203 classifications[ring_idx] = Some((cls, count));
204 if matches!(cls, RingAromaticity::Aromatic) {
205 mark_ring_aromatic(mol, ring, &mut aromatic_atoms, &mut aromatic_bonds);
206 any_new = true;
207 }
208 }
210 None => {
211 still_pending.push(ring_idx);
212 }
213 }
214 }
215
216 pass2_candidates = still_pending;
217 if !any_new {
218 break;
219 }
220 }
221
222 let ring_classifications: Vec<(Vec<AtomIdx>, RingAromaticity, u32)> = rings
224 .iter()
225 .take(sssr_rings.len()) .enumerate()
227 .filter_map(|(i, ring)| classifications[i].map(|(cls, count)| (ring.to_vec(), cls, count)))
228 .collect();
229
230 AromaticityModel {
231 aromatic_atoms,
232 aromatic_bonds,
233 antiaromatic_rings,
234 ring_classifications,
235 }
236}
237
238pub fn apply_aromaticity(mol: &Molecule) -> Molecule {
247 use chematic_core::{BondOrder, MoleculeBuilder};
248
249 let model = assign_aromaticity(mol);
250 let mut builder = MoleculeBuilder::new();
251
252 for (idx, atom) in mol.atoms() {
253 let mut a = atom.clone();
254 if model.is_atom_aromatic(idx) {
255 a.aromatic = true;
256 }
257 builder.add_atom(a);
258 }
259 for (bidx, bond) in mol.bonds() {
260 let order = if model.is_bond_aromatic(bidx) {
261 BondOrder::Aromatic
262 } else {
263 bond.order
264 };
265 let _ = builder.add_bond(bond.atom1, bond.atom2, order);
266 }
267 builder.build()
268}
269
270fn ring_bond_set(mol: &Molecule, ring: &[AtomIdx]) -> Vec<BondIdx> {
276 let n = ring.len();
277 let mut bonds: Vec<BondIdx> = (0..n)
278 .filter_map(|i| {
279 let a = ring[i];
280 let b = ring[(i + 1) % n];
281 mol.bond_between(a, b).map(|(bidx, _)| bidx)
282 })
283 .collect();
284 bonds.sort();
285 bonds
286}
287
288fn bond_sym_diff(a: &[BondIdx], b: &[BondIdx]) -> Vec<BondIdx> {
290 let mut result: Vec<BondIdx> = Vec::new();
291 let mut i = 0;
292 let mut j = 0;
293 while i < a.len() && j < b.len() {
294 match a[i].cmp(&b[j]) {
295 std::cmp::Ordering::Less => {
296 result.push(a[i]);
297 i += 1;
298 }
299 std::cmp::Ordering::Greater => {
300 result.push(b[j]);
301 j += 1;
302 }
303 std::cmp::Ordering::Equal => {
304 i += 1;
305 j += 1;
306 }
307 }
308 }
309 result.extend_from_slice(&a[i..]);
310 result.extend_from_slice(&b[j..]);
311 result
312}
313
314fn ring_atoms_from_bond_set(mol: &Molecule, bonds: &[BondIdx]) -> Option<Vec<AtomIdx>> {
317 if bonds.is_empty() {
318 return None;
319 }
320 let mut adj: FxHashMap<AtomIdx, [Option<AtomIdx>; 2]> = FxHashMap::default();
321 for &bidx in bonds {
322 let bond = mol.bond(bidx);
323 for (a, b) in [(bond.atom1, bond.atom2), (bond.atom2, bond.atom1)] {
324 let e = adj.entry(a).or_insert([None; 2]);
325 if e[0].is_none() {
326 e[0] = Some(b);
327 } else if e[1].is_none() {
328 e[1] = Some(b);
329 } else {
330 return None; }
332 }
333 }
334 if adj.values().any(|e| e[1].is_none()) {
336 return None;
337 }
338 let start = *adj.keys().next()?;
339 let mut path = vec![start];
340 let mut prev = start;
341 let mut current = adj[&start][0]?;
342 while current != start {
343 path.push(current);
344 let [n0, n1] = adj[¤t];
345 let next = if n0 == Some(prev) { n1? } else { n0? };
346 prev = current;
347 current = next;
348 }
349 if path.len() != bonds.len() {
350 return None;
351 }
352 Some(path)
353}
354
355pub fn augmented_ring_set(mol: &Molecule, sssr_rings: &[Vec<AtomIdx>]) -> Vec<Vec<AtomIdx>> {
370 let mut rings: Vec<Vec<AtomIdx>> = sssr_rings.to_vec();
371
372 let mut known: FxHashSet<Vec<AtomIdx>> = sssr_rings
374 .iter()
375 .map(|r| {
376 let mut s = r.clone();
377 s.sort();
378 s
379 })
380 .collect();
381
382 loop {
391 let mut changed = false;
392 let n = rings.len();
393 let bond_sets: Vec<Vec<BondIdx>> = rings.iter().map(|r| ring_bond_set(mol, r)).collect();
394
395 for i in 0..n {
396 for j in (i + 1)..n {
397 let shares_atom = rings[i].iter().any(|a| rings[j].contains(a));
399 if !shares_atom {
400 continue;
401 }
402 let xor_bonds = bond_sym_diff(&bond_sets[i], &bond_sets[j]);
403 if xor_bonds.is_empty() {
404 continue;
405 }
406 if xor_bonds.len() >= rings[i].len().max(rings[j].len()) {
414 continue;
415 }
416 if let Some(new_ring) = ring_atoms_from_bond_set(mol, &xor_bonds) {
417 let mut key = new_ring.clone();
418 key.sort();
419 if known.insert(key) {
420 rings.push(new_ring);
421 changed = true;
422 }
423 }
424 }
425 }
426
427 if !changed {
428 break;
429 }
430 }
431
432 rings
433}
434
435pub fn count_aromatic_rings(mol: &Molecule) -> usize {
443 let mol_with_arom;
446 let mol = if mol.atoms().any(|(_, a)| a.aromatic) {
447 mol } else {
449 mol_with_arom = apply_aromaticity(mol);
450 &mol_with_arom
451 };
452
453 let sssr = crate::sssr::find_sssr(mol);
454 let aug = augmented_ring_set(mol, sssr.rings());
455
456 let aromatic: Vec<Vec<AtomIdx>> = aug
458 .into_iter()
459 .filter(|ring| ring.iter().all(|&idx| mol.atom(idx).aromatic))
460 .collect();
461
462 if aromatic.len() <= 1 {
463 return aromatic.len();
464 }
465
466 let bond_sets: Vec<Vec<BondIdx>> = aromatic.iter().map(|r| ring_bond_set(mol, r)).collect();
468
469 let n = aromatic.len();
478 let mut is_envelope = vec![false; n];
479 for i in 0..n {
480 let si = aromatic[i].len();
481
482 'jk: for j in 0..n {
484 if j == i || aromatic[j].len() >= si {
485 continue;
486 }
487 for k in (j + 1)..n {
488 if k == i || aromatic[k].len() >= si {
489 continue;
490 }
491 let xor = bond_sym_diff(&bond_sets[j], &bond_sets[k]);
492 if xor == bond_sets[i] {
493 is_envelope[i] = true;
494 break 'jk;
495 }
496 }
497 }
498
499 if !is_envelope[i] {
501 'jkl: for j in 0..n {
502 if j == i || aromatic[j].len() >= si {
503 continue;
504 }
505 for k in (j + 1)..n {
506 if k == i || aromatic[k].len() >= si {
507 continue;
508 }
509 let xor_jk = bond_sym_diff(&bond_sets[j], &bond_sets[k]);
510 for l in (k + 1)..n {
511 if l == i || aromatic[l].len() >= si {
512 continue;
513 }
514 let xor_jkl = bond_sym_diff(&xor_jk, &bond_sets[l]);
515 if xor_jkl == bond_sets[i] {
516 is_envelope[i] = true;
517 break 'jkl;
518 }
519 }
520 }
521 }
522 }
523
524 if !is_envelope[i] {
528 'jklm: for j in 0..n {
529 if j == i || aromatic[j].len() >= si {
530 continue;
531 }
532 for k in (j + 1)..n {
533 if k == i || aromatic[k].len() >= si {
534 continue;
535 }
536 let xor_jk = bond_sym_diff(&bond_sets[j], &bond_sets[k]);
537 for l in (k + 1)..n {
538 if l == i || aromatic[l].len() >= si {
539 continue;
540 }
541 let xor_jkl = bond_sym_diff(&xor_jk, &bond_sets[l]);
542 for m in (l + 1)..n {
543 if m == i || aromatic[m].len() >= si {
544 continue;
545 }
546 let xor_jklm = bond_sym_diff(&xor_jkl, &bond_sets[m]);
547 if xor_jklm == bond_sets[i] {
548 is_envelope[i] = true;
549 break 'jklm;
550 }
551 }
552 }
553 }
554 }
555 }
556 }
557
558 is_envelope.iter().filter(|&&e| !e).count()
559}
560
561fn ring_pi_electrons(
586 mol: &Molecule,
587 ring: &[AtomIdx],
588 aromatic_context: &FxHashSet<AtomIdx>,
589) -> Option<u32> {
590 let ring_atom_set: FxHashSet<AtomIdx> = ring.iter().copied().collect();
591 let mut total_pi: u32 = 0;
592
593 for &atom_idx in ring {
594 if aromatic_context.contains(&atom_idx) {
596 total_pi += 1;
597 continue;
598 }
599
600 let atom = mol.atom(atom_idx);
601 let an = atom.element.atomic_number();
602
603 let ring_degree = mol
604 .neighbors(atom_idx)
605 .filter(|(nb, _)| ring_atom_set.contains(nb))
606 .count();
607
608 let total_degree = mol.degree(atom_idx);
609
610 let has_explicit_double = mol
612 .neighbors(atom_idx)
613 .any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Double);
614
615 let has_double_any = has_explicit_double
617 || mol
618 .neighbors(atom_idx)
619 .any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Aromatic);
620
621 let has_aromatic_in_ring = mol
623 .neighbors(atom_idx)
624 .filter(|(nb, _)| ring_atom_set.contains(nb))
625 .any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Aromatic);
626
627 let pi = match an {
628 6 => {
630 if !has_double_any {
631 return None; }
633 1
634 }
635
636 7 => {
638 if implicit_hcount(mol, atom_idx) > 0 {
639 2
641 } else if has_explicit_double {
642 1
644 } else if total_degree == 3 && ring_degree < total_degree {
645 2
649 } else if has_aromatic_in_ring {
650 1
653 } else {
654 return None;
656 }
657 }
658
659 8 | 16 => {
661 if ring_degree != 2 {
662 return None;
663 }
664 2
665 }
666
667 _ => return None,
669 };
670
671 total_pi += pi;
672 }
673
674 Some(total_pi)
675}
676
677#[cfg(test)]
682mod tests {
683 use super::*;
684 use chematic_core::{Atom, BondOrder, Element, MoleculeBuilder};
685
686 fn benzene_kekule() -> chematic_core::Molecule {
691 let mut b = MoleculeBuilder::new();
692 let atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
693 for i in 0..6 {
694 let order = if i % 2 == 0 {
695 BondOrder::Double
696 } else {
697 BondOrder::Single
698 };
699 b.add_bond(atoms[i], atoms[(i + 1) % 6], order).unwrap();
700 }
701 b.build()
702 }
703
704 fn cyclohexane() -> chematic_core::Molecule {
705 let mut b = MoleculeBuilder::new();
706 let atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
707 for i in 0..6 {
708 b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Single)
709 .unwrap();
710 }
711 b.build()
712 }
713
714 fn pyridine_kekule() -> chematic_core::Molecule {
715 let mut b = MoleculeBuilder::new();
716 let n = b.add_atom(Atom::new(Element::N));
717 let atoms_c: Vec<_> = (0..5).map(|_| b.add_atom(Atom::new(Element::C))).collect();
718 let ring = [
719 n, atoms_c[0], atoms_c[1], atoms_c[2], atoms_c[3], atoms_c[4],
720 ];
721 for i in 0..6 {
722 let order = if i % 2 == 0 {
723 BondOrder::Double
724 } else {
725 BondOrder::Single
726 };
727 b.add_bond(ring[i], ring[(i + 1) % 6], order).unwrap();
728 }
729 b.build()
730 }
731
732 fn furan_kekule() -> chematic_core::Molecule {
733 let mut b = MoleculeBuilder::new();
734 let o = b.add_atom(Atom::new(Element::O));
735 let c1 = b.add_atom(Atom::new(Element::C));
736 let c2 = b.add_atom(Atom::new(Element::C));
737 let c3 = b.add_atom(Atom::new(Element::C));
738 let c4 = b.add_atom(Atom::new(Element::C));
739 let ring = [o, c1, c2, c3, c4];
740 b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
741 b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
742 b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
743 b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
744 b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
745 b.build()
746 }
747
748 fn pyrrole_kekule() -> chematic_core::Molecule {
749 let mut b = MoleculeBuilder::new();
750 let mut n_atom = Atom::new(Element::N);
751 n_atom.hydrogen_count = Some(1);
752 let n = b.add_atom(n_atom);
753 let c1 = b.add_atom(Atom::new(Element::C));
754 let c2 = b.add_atom(Atom::new(Element::C));
755 let c3 = b.add_atom(Atom::new(Element::C));
756 let c4 = b.add_atom(Atom::new(Element::C));
757 let ring = [n, c1, c2, c3, c4];
758 b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
759 b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
760 b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
761 b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
762 b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
763 b.build()
764 }
765
766 fn naphthalene_kekule() -> chematic_core::Molecule {
767 let mut b = MoleculeBuilder::new();
768 let atoms: Vec<_> = (0..10).map(|_| b.add_atom(Atom::new(Element::C))).collect();
769 let ring1 = [0usize, 1, 2, 3, 4, 9];
770 let orders1 = [
771 BondOrder::Double,
772 BondOrder::Single,
773 BondOrder::Double,
774 BondOrder::Single,
775 BondOrder::Double,
776 BondOrder::Single,
777 ];
778 for i in 0..6 {
779 b.add_bond(atoms[ring1[i]], atoms[ring1[(i + 1) % 6]], orders1[i])
780 .unwrap();
781 }
782 let ring2_extra = [(4, 5), (5, 6), (6, 7), (7, 8), (8, 9)];
783 let orders2 = [
784 BondOrder::Single,
785 BondOrder::Double,
786 BondOrder::Single,
787 BondOrder::Double,
788 BondOrder::Single,
789 ];
790 for (i, &(a, bb)) in ring2_extra.iter().enumerate() {
791 b.add_bond(atoms[a], atoms[bb], orders2[i]).unwrap();
792 }
793 b.build()
794 }
795
796 fn cyclobutadiene_kekule() -> chematic_core::Molecule {
797 let mut b = MoleculeBuilder::new();
798 let atoms: Vec<_> = (0..4).map(|_| b.add_atom(Atom::new(Element::C))).collect();
799 for i in 0..4 {
800 let order = if i % 2 == 0 {
801 BondOrder::Double
802 } else {
803 BondOrder::Single
804 };
805 b.add_bond(atoms[i], atoms[(i + 1) % 4], order).unwrap();
806 }
807 b.build()
808 }
809
810 fn cyclooctatetraene_kekule() -> chematic_core::Molecule {
811 let mut b = MoleculeBuilder::new();
812 let atoms: Vec<_> = (0..8).map(|_| b.add_atom(Atom::new(Element::C))).collect();
813 for i in 0..8 {
814 let order = if i % 2 == 0 {
815 BondOrder::Double
816 } else {
817 BondOrder::Single
818 };
819 b.add_bond(atoms[i], atoms[(i + 1) % 8], order).unwrap();
820 }
821 b.build()
822 }
823
824 #[cfg(test)]
827 fn mol_aromatic(smiles: &str) -> chematic_core::Molecule {
828 chematic_smiles::parse(smiles).expect("valid SMILES")
829 }
830
831 #[cfg(test)]
833 fn mol_kekulized(smiles: &str) -> chematic_core::Molecule {
834 let mol = chematic_smiles::parse(smiles).expect("valid SMILES");
835 let k = chematic_core::kekulize(&mol).expect("kekulizable");
836 chematic_core::apply_kekule(&mol, &k)
837 }
838
839 #[test]
844 fn test_benzene_is_aromatic() {
845 let mol = benzene_kekule();
846 let model = assign_aromaticity(&mol);
847 assert_eq!(
848 model.aromatic_atom_count(),
849 6,
850 "all 6 benzene atoms aromatic"
851 );
852 for i in 0..6u32 {
853 assert!(model.is_atom_aromatic(AtomIdx(i)));
854 }
855 }
856
857 #[test]
858 fn test_cyclohexane_not_aromatic() {
859 let mol = cyclohexane();
860 let model = assign_aromaticity(&mol);
861 assert_eq!(model.aromatic_atom_count(), 0, "cyclohexane not aromatic");
862 }
863
864 #[test]
865 fn test_pyridine_is_aromatic() {
866 let mol = pyridine_kekule();
867 let model = assign_aromaticity(&mol);
868 assert_eq!(model.aromatic_atom_count(), 6);
869 }
870
871 #[test]
872 fn test_furan_is_aromatic() {
873 let mol = furan_kekule();
874 let model = assign_aromaticity(&mol);
875 assert_eq!(model.aromatic_atom_count(), 5);
876 }
877
878 #[test]
879 fn test_pyrrole_is_aromatic() {
880 let mol = pyrrole_kekule();
881 let model = assign_aromaticity(&mol);
882 assert_eq!(model.aromatic_atom_count(), 5);
883 }
884
885 #[test]
886 fn test_naphthalene_both_rings_aromatic() {
887 let mol = naphthalene_kekule();
888 let model = assign_aromaticity(&mol);
889 assert_eq!(
890 model.aromatic_atom_count(),
891 10,
892 "all 10 naphthalene atoms aromatic"
893 );
894 }
895
896 #[test]
897 fn test_bond_aromaticity_benzene() {
898 let mol = benzene_kekule();
899 let model = assign_aromaticity(&mol);
900 let count = mol
901 .bonds()
902 .filter(|(b, _)| model.is_bond_aromatic(*b))
903 .count();
904 assert_eq!(count, 6);
905 }
906
907 #[test]
908 fn test_apply_aromaticity_benzene() {
909 let mol = benzene_kekule();
910 let aromatic = apply_aromaticity(&mol);
911 for (_, atom) in aromatic.atoms() {
912 assert!(atom.aromatic, "every benzene carbon should be aromatic");
913 }
914 let aromatic_bond_count = aromatic
915 .bonds()
916 .filter(|(_, b)| b.order == BondOrder::Aromatic)
917 .count();
918 assert_eq!(aromatic_bond_count, 6);
919 }
920
921 #[test]
922 fn test_apply_aromaticity_cyclohexane_unchanged() {
923 let mol = cyclohexane();
924 let result = apply_aromaticity(&mol);
925 for (_, atom) in result.atoms() {
926 assert!(!atom.aromatic);
927 }
928 for (_, bond) in result.bonds() {
929 assert_ne!(bond.order, BondOrder::Aromatic);
930 }
931 }
932
933 #[test]
938 fn test_cyclobutadiene_antiaromatic() {
939 let mol = cyclobutadiene_kekule();
940 let model = assign_aromaticity(&mol);
941 assert_eq!(
942 model.aromatic_atom_count(),
943 0,
944 "cyclobutadiene not aromatic"
945 );
946 assert!(model.has_antiaromaticity(), "cyclobutadiene antiaromatic");
947 assert_eq!(model.antiaromatic_rings().len(), 1);
948 let classifications = model.ring_classifications();
949 assert_eq!(classifications.len(), 1);
950 assert_eq!(classifications[0].1, RingAromaticity::Antiaromatic);
951 assert_eq!(classifications[0].2, 4);
952 }
953
954 #[test]
955 fn test_cyclooctatetraene_antiaromatic() {
956 let mol = cyclooctatetraene_kekule();
957 let model = assign_aromaticity(&mol);
958 assert_eq!(model.aromatic_atom_count(), 0, "COT not aromatic");
959 assert!(model.has_antiaromaticity(), "COT antiaromatic");
960 assert_eq!(model.antiaromatic_rings().len(), 1);
961 let cls = &model.ring_classifications()[0];
962 assert_eq!(cls.1, RingAromaticity::Antiaromatic);
963 assert_eq!(cls.2, 8);
964 }
965
966 #[test]
971 fn test_ring_classifications_benzene() {
972 let mol = benzene_kekule();
973 let model = assign_aromaticity(&mol);
974 let classifications = model.ring_classifications();
975 assert_eq!(classifications.len(), 1);
976 assert_eq!(classifications[0].1, RingAromaticity::Aromatic);
977 assert_eq!(classifications[0].2, 6);
978 }
979
980 #[test]
981 fn test_ring_classifications_naphthalene() {
982 let mol = naphthalene_kekule();
983 let model = assign_aromaticity(&mol);
984 let classifications = model.ring_classifications();
985 assert_eq!(classifications.len(), 2, "naphthalene has two rings");
986 for (_, classification, count) in classifications {
987 assert_eq!(*classification, RingAromaticity::Aromatic);
988 assert_eq!(*count, 6);
989 }
990 }
991
992 #[test]
993 fn test_non_aromatic_cyclohexane() {
994 let mol = cyclohexane();
995 let model = assign_aromaticity(&mol);
996 for (_, classification, _) in model.ring_classifications() {
997 assert_ne!(*classification, RingAromaticity::Aromatic);
998 assert_ne!(*classification, RingAromaticity::Antiaromatic);
999 }
1000 }
1001
1002 #[test]
1007 fn test_thiophene_aromatic() {
1008 let mut b = MoleculeBuilder::new();
1009 let s = b.add_atom(Atom::new(Element::S));
1010 let c1 = b.add_atom(Atom::new(Element::C));
1011 let c2 = b.add_atom(Atom::new(Element::C));
1012 let c3 = b.add_atom(Atom::new(Element::C));
1013 let c4 = b.add_atom(Atom::new(Element::C));
1014 let ring = [s, c1, c2, c3, c4];
1015 b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
1016 b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
1017 b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
1018 b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
1019 b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
1020 let mol = b.build();
1021 let model = assign_aromaticity(&mol);
1022 assert_eq!(model.aromatic_atom_count(), 5);
1023 assert_eq!(model.ring_classifications()[0].2, 6);
1024 }
1025
1026 #[test]
1027 fn test_electron_distribution_tracking() {
1028 let mol = benzene_kekule();
1029 let model = assign_aromaticity(&mol);
1030 assert_eq!(model.ring_classifications()[0].2, 6, "benzene: 6 × 1π = 6");
1031
1032 let mol = pyrrole_kekule();
1033 let model = assign_aromaticity(&mol);
1034 assert_eq!(
1035 model.ring_classifications()[0].2,
1036 6,
1037 "pyrrole: N(2π) + 4C(1π) = 6"
1038 );
1039
1040 let mol = furan_kekule();
1041 let model = assign_aromaticity(&mol);
1042 assert_eq!(
1043 model.ring_classifications()[0].2,
1044 6,
1045 "furan: O(2π) + 4C(1π) = 6"
1046 );
1047 }
1048
1049 #[test]
1055 fn test_benzene_aromatic_smiles() {
1056 let mol = mol_aromatic("c1ccccc1");
1058 let model = assign_aromaticity(&mol);
1059 assert_eq!(
1060 model.aromatic_atom_count(),
1061 6,
1062 "benzene from aromatic SMILES"
1063 );
1064 }
1065
1066 #[test]
1067 fn test_naphthalene_aromatic_smiles() {
1068 let mol = mol_aromatic("c1ccc2ccccc2c1");
1069 let model = assign_aromaticity(&mol);
1070 assert_eq!(
1071 model.aromatic_atom_count(),
1072 10,
1073 "naphthalene from aromatic SMILES"
1074 );
1075 }
1076
1077 #[test]
1078 fn test_pyridine_aromatic_smiles() {
1079 let mol = mol_aromatic("c1ccncc1");
1080 let model = assign_aromaticity(&mol);
1081 assert_eq!(
1082 model.aromatic_atom_count(),
1083 6,
1084 "pyridine from aromatic SMILES"
1085 );
1086 }
1087
1088 #[test]
1089 fn test_furan_aromatic_smiles() {
1090 let mol = mol_aromatic("c1ccoc1");
1091 let model = assign_aromaticity(&mol);
1092 assert_eq!(model.aromatic_atom_count(), 5, "furan from aromatic SMILES");
1093 }
1094
1095 #[test]
1096 fn test_pyrrole_aromatic_smiles() {
1097 let mol = mol_aromatic("c1cc[nH]c1");
1099 let model = assign_aromaticity(&mol);
1100 assert_eq!(
1101 model.aromatic_atom_count(),
1102 5,
1103 "pyrrole from aromatic SMILES"
1104 );
1105 }
1106
1107 #[test]
1108 fn test_thiophene_aromatic_smiles() {
1109 let mol = mol_aromatic("c1ccsc1");
1110 let model = assign_aromaticity(&mol);
1111 assert_eq!(
1112 model.aromatic_atom_count(),
1113 5,
1114 "thiophene from aromatic SMILES"
1115 );
1116 }
1117
1118 #[test]
1123 fn test_indole_aromatic() {
1124 let mol = mol_kekulized("c1ccc2[nH]ccc2c1");
1126 let model = assign_aromaticity(&mol);
1127 assert_eq!(
1128 model.aromatic_atom_count(),
1129 9,
1130 "all 9 indole atoms aromatic"
1131 );
1132 }
1133
1134 #[test]
1135 fn test_benzimidazole_aromatic() {
1136 let mol = mol_kekulized("c1ccc2[nH]cnc2c1");
1138 let model = assign_aromaticity(&mol);
1139 assert_eq!(model.aromatic_atom_count(), 9, "all 9 benzimidazole atoms");
1140 }
1141
1142 #[test]
1143 fn test_quinoline_aromatic() {
1144 let mol = mol_kekulized("c1ccc2ncccc2c1");
1145 let model = assign_aromaticity(&mol);
1146 assert_eq!(model.aromatic_atom_count(), 10, "all 10 quinoline atoms");
1147 }
1148
1149 #[test]
1150 fn test_acridine_aromatic() {
1151 let mol = mol_kekulized("c1ccc2nc3ccccc3cc2c1");
1153 let model = assign_aromaticity(&mol);
1154 assert_eq!(model.aromatic_atom_count(), 14, "all 14 acridine atoms");
1156 }
1157
1158 #[test]
1163 fn test_indolizine_aromatic() {
1164 let mol = mol_aromatic("c1ccn2cccc2c1");
1172 let model = assign_aromaticity(&mol);
1173 assert_eq!(
1174 model.aromatic_atom_count(),
1175 9,
1176 "all 9 indolizine atoms aromatic"
1177 );
1178 let has_aromatic_ring = model
1180 .ring_classifications()
1181 .iter()
1182 .any(|(_, cls, _)| *cls == RingAromaticity::Aromatic);
1183 assert!(has_aromatic_ring, "at least one SSSR ring aromatic");
1184 }
1185
1186 #[test]
1187 fn test_purine_aromatic() {
1188 let mol = mol_kekulized("c1cnc2[nH]cnc2n1");
1190 let model = assign_aromaticity(&mol);
1191 assert_eq!(
1192 model.aromatic_atom_count(),
1193 9,
1194 "all 9 purine atoms aromatic"
1195 );
1196 }
1197
1198 #[test]
1199 fn test_purine_aromatic_from_aromatic_smiles() {
1200 let mol = mol_aromatic("c1cnc2[nH]cnc2n1");
1201 let model = assign_aromaticity(&mol);
1202 assert_eq!(
1203 model.aromatic_atom_count(),
1204 9,
1205 "purine from aromatic SMILES"
1206 );
1207 }
1208
1209 #[test]
1210 fn test_2_pyridinone_aromatic() {
1211 let mol = mol_aromatic("O=c1ccncc1");
1217 let model = assign_aromaticity(&mol);
1218 assert_eq!(
1219 model.aromatic_atom_count(),
1220 6,
1221 "all 6 ring atoms of 2-pyridinone aromatic"
1222 );
1223 }
1224
1225 #[test]
1226 fn test_quinolone_aromatic() {
1227 let mol = mol_aromatic("O=c1ccc2ncccc2c1");
1229 let model = assign_aromaticity(&mol);
1230 assert_eq!(
1231 model.aromatic_atom_count(),
1232 10,
1233 "all 10 quinolone ring atoms aromatic"
1234 );
1235 assert_eq!(
1236 model.ring_classifications().len(),
1237 2,
1238 "two rings classified"
1239 );
1240 }
1241
1242 #[test]
1243 fn test_indole_aromatic_smiles() {
1244 let mol = mol_aromatic("c1ccc2[nH]ccc2c1");
1245 let model = assign_aromaticity(&mol);
1246 assert_eq!(
1247 model.aromatic_atom_count(),
1248 9,
1249 "indole from aromatic SMILES"
1250 );
1251 }
1252
1253 #[test]
1258 fn test_bridgehead_n_contributes_lone_pair() {
1259 let mol = mol_aromatic("c1ccn2cccc2c1");
1263 let model = assign_aromaticity(&mol);
1264 assert_eq!(model.aromatic_atom_count(), 9);
1266 assert!(
1269 model.is_atom_aromatic(AtomIdx(3)),
1270 "bridgehead N must be aromatic"
1271 );
1272 }
1273
1274 #[test]
1275 fn test_non_bridgehead_n_no_false_positive() {
1276 let mol = mol_aromatic("c1ccncn1");
1280 let model = assign_aromaticity(&mol);
1281 assert_eq!(model.aromatic_atom_count(), 6, "pyrimidine is aromatic");
1282 }
1283
1284 #[test]
1285 fn test_imidazole_aromatic() {
1286 let mol = mol_aromatic("c1cn[nH]c1");
1288 let model = assign_aromaticity(&mol);
1289 assert_eq!(model.aromatic_atom_count(), 5, "imidazole is aromatic");
1290 }
1291
1292 #[test]
1297 fn test_pass2_needed_for_indolizine_6ring() {
1298 let mol = mol_aromatic("c1ccn2cccc2c1");
1303 let model = assign_aromaticity(&mol);
1304 assert_eq!(
1305 model.aromatic_atom_count(),
1306 9,
1307 "all 9 indolizine atoms aromatic"
1308 );
1309 assert!(
1311 model.is_atom_aromatic(AtomIdx(3)),
1312 "bridgehead N is aromatic"
1313 );
1314 let aromatic_count = model
1316 .ring_classifications()
1317 .iter()
1318 .filter(|(_, cls, _)| *cls == RingAromaticity::Aromatic)
1319 .count();
1320 assert!(aromatic_count >= 1, "at least one SSSR ring is aromatic");
1321 }
1322
1323 #[test]
1324 fn test_no_pass2_needed_for_naphthalene() {
1325 let mol = naphthalene_kekule();
1328 let model = assign_aromaticity(&mol);
1329 assert_eq!(model.aromatic_atom_count(), 10);
1330 let classes = model.ring_classifications();
1331 assert_eq!(classes.len(), 2);
1332 for (_, cls, _) in classes {
1333 assert_eq!(*cls, RingAromaticity::Aromatic);
1334 }
1335 }
1336
1337 #[test]
1338 fn test_anthracene_aromatic() {
1339 let mol = mol_kekulized("c1ccc2cc3ccccc3cc2c1");
1341 let model = assign_aromaticity(&mol);
1342 assert_eq!(model.aromatic_atom_count(), 14, "all 14 anthracene atoms");
1343 }
1344
1345 #[test]
1350 fn test_kekulized_path_unaffected_by_aromatic_bond_changes() {
1351 let mol = benzene_kekule();
1354 for (_, bond) in mol.bonds() {
1356 assert_ne!(bond.order, BondOrder::Aromatic, "input must be kekulized");
1357 }
1358 let model = assign_aromaticity(&mol);
1359 assert_eq!(model.aromatic_atom_count(), 6);
1360 let aromatic_bonds = mol
1362 .bonds()
1363 .filter(|(b, _)| model.is_bond_aromatic(*b))
1364 .count();
1365 assert_eq!(aromatic_bonds, 6);
1366 }
1367
1368 #[test]
1369 fn test_keto_pyridinone_not_huckel_aromatic() {
1370 let mol = mol_kekulized("O=C1NC=CC=C1");
1377 let model = assign_aromaticity(&mol);
1378 assert_eq!(
1379 model.aromatic_atom_count(),
1380 0,
1381 "keto pyridinone is not Hückel aromatic (7π ≠ 4n+2)"
1382 );
1383 }
1384
1385 #[test]
1388 fn test_fluorescein_dianion_aromatic() {
1389 let smi = "C1=CC=C(C(=C1)C2=C3C=CC(=O)C=C3OC4=C2C=CC(=C4)[O-])C(=O)[O-]";
1394 let mol = chematic_smiles::parse(smi).expect("fluorescein dianion should parse");
1395 let arc = count_aromatic_rings(&mol);
1398 assert!(
1399 arc >= 2,
1400 "fluorescein dianion: expected ≥2 aromatic rings, got {arc} \
1401 (RDKit #9271: charged aromatics may be misclassified)"
1402 );
1403 }
1404
1405 #[test]
1406 fn test_rhodamine_zwitterion_parses() {
1407 let smi = "CCN(CC)c1ccc2c(-c3ccccc3C(=O)O)c3ccc(=[N+](CC)CC)cc-3oc2c1";
1410 let mol = chematic_smiles::parse(smi).expect("rhodamine zwitterion should parse");
1411 let arc = count_aromatic_rings(&mol);
1412 assert!(arc >= 3, "rhodamine: expected ≥3 aromatic rings, got {arc}");
1413 }
1414
1415 #[test]
1416 fn test_cyclopentadienyl_not_aromatic_kekulized() {
1417 let mut b = MoleculeBuilder::new();
1419 let c0 = b.add_atom(Atom::new(Element::C)); let c1 = b.add_atom(Atom::new(Element::C));
1421 let c2 = b.add_atom(Atom::new(Element::C));
1422 let c3 = b.add_atom(Atom::new(Element::C));
1423 let c4 = b.add_atom(Atom::new(Element::C));
1424 b.add_bond(c0, c1, BondOrder::Single).unwrap();
1425 b.add_bond(c1, c2, BondOrder::Double).unwrap();
1426 b.add_bond(c2, c3, BondOrder::Single).unwrap();
1427 b.add_bond(c3, c4, BondOrder::Double).unwrap();
1428 b.add_bond(c4, c0, BondOrder::Single).unwrap();
1429 let mol = b.build();
1430 let model = assign_aromaticity(&mol);
1431 assert_eq!(
1432 model.aromatic_atom_count(),
1433 0,
1434 "cyclopentadiene not aromatic"
1435 );
1436 }
1437}