use omgkit_core::{element, AtomFlags, BondFlags, BondOrder, MolBuilder};
use crate::sssr::{ring_set, Ring};
use crate::valence::explicit_valence_nonstrict;
const MAX_FUSED_RINGS: usize = 6;
const MAX_FUSED_RING_SIZE: usize = 24;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Donor {
None,
Vacant,
One,
Two,
Any,
}
impl Donor {
fn range(self) -> (i32, i32) {
match self {
Self::One => (1, 1),
Self::Two => (2, 2),
Self::Any => (1, 2),
Self::None | Self::Vacant => (0, 0),
}
}
fn can_be_aromatic(self) -> bool {
!matches!(self, Self::None)
}
}
pub fn set_aromaticity(mol: &mut MolBuilder) -> usize {
let rings = ring_set(mol);
if rings.is_empty() {
return 0;
}
let n_atoms = mol.num_atoms();
let donors: Vec<Donor> = (0..n_atoms as u32).map(|i| donor_type(mol, i)).collect();
let candidate: Vec<bool> = (0..n_atoms as u32)
.map(|i| donors[i as usize].can_be_aromatic() && is_arom_candidate(mol, i))
.collect();
let cand_rings: Vec<&Ring> = rings
.iter()
.filter(|r| {
r.atoms.iter().all(|&a| candidate[a as usize])
&& r.atoms
.iter()
.any(|&a| mol.atoms()[a as usize].atomic_num != 0)
})
.collect();
if cand_rings.is_empty() {
return 0;
}
let neigh = ring_neighbors(&cand_rings, mol.num_bonds());
let mut n_arom = 0usize;
let mut done = vec![false; cand_rings.len()];
let mut scratch = Scratch::new(n_atoms, mol.num_bonds());
for start in 0..cand_rings.len() {
if done[start] {
continue;
}
let group = collect_group(start, &neigh, &mut done);
n_arom += huckel_on_group(mol, &cand_rings, &group, &neigh, &donors, &mut scratch);
}
n_arom
}
struct Scratch {
ring_count: Vec<u32>,
marked_bonds: Vec<bool>,
bond_seen: Vec<bool>,
}
impl Scratch {
fn new(n_atoms: usize, n_bonds: usize) -> Self {
Self {
ring_count: vec![0; n_atoms],
marked_bonds: vec![false; n_bonds],
bond_seen: vec![false; n_bonds],
}
}
}
pub(crate) fn count_pi_electrons(mol: &MolBuilder, idx: u32) -> Option<i32> {
let atom = mol.atoms()[idx as usize];
let z = atom.atomic_num;
let dv = element::by_atomic_num(z)
.and_then(|e| e.valences.first().copied())
.map_or(-1, i32::from);
if dv <= 1 {
return None;
}
let mut degree = mol.degree(idx) as i32 + i32::from(total_hs(mol, idx));
for (_, bi) in mol.neighbors(idx) {
if mol.bonds()[bi as usize].valence_contribution_to(idx) == 0.0 {
degree -= 1;
}
}
if degree > 3 {
return None;
}
let n_outer = i32::from(element::by_atomic_num(z).map_or(0, |e| e.outer_electrons));
let n_lone_pairs = (n_outer - dv - i32::from(atom.formal_charge)).max(0);
let n_radicals = i32::from(atom.num_radical_electrons);
let mut res = (dv - degree) + n_lone_pairs - n_radicals;
if res > 1 {
if unsaturations(mol, idx) > 1 {
res = 1;
}
}
Some(res)
}
fn total_hs(mol: &MolBuilder, idx: u32) -> u8 {
let a = mol.atoms()[idx as usize];
a.num_explicit_hs + a.num_implicit_hs
}
fn unsaturations(mol: &MolBuilder, idx: u32) -> i32 {
explicit_valence_nonstrict(mol, idx) - mol.degree(idx) as i32
}
fn exocyclic_multiple_bond(mol: &MolBuilder, idx: u32) -> Option<u32> {
mol.neighbors(idx).find_map(|(other, bi)| {
let b = mol.bonds()[bi as usize];
(!b.flags.contains(BondFlags::IN_RING) && b.valence_contribution_to(idx) >= 2.0)
.then_some(other)
})
}
fn cyclic_multiple_bond(mol: &MolBuilder, idx: u32) -> bool {
mol.neighbors(idx).any(|(_, bi)| {
let b = mol.bonds()[bi as usize];
b.flags.contains(BondFlags::IN_RING) && b.valence_contribution_to(idx) >= 2.0
})
}
fn has_multiple_bond(mol: &MolBuilder, idx: u32) -> bool {
let mut deg = mol.degree(idx) as i32 + i32::from(mol.atoms()[idx as usize].num_explicit_hs);
for (_, bi) in mol.neighbors(idx) {
if mol.bonds()[bi as usize]
.valence_contribution_to(idx)
.round() as i32
== 0
{
deg -= 1;
}
}
explicit_valence_nonstrict(mol, idx) != deg
}
fn more_electronegative(z1: u8, z2: u8) -> bool {
let e = |z: u8| element::by_atomic_num(z).map_or(0, |x| x.outer_electrons);
let (n1, n2) = (e(z1), e(z2));
n1 > n2 || (n1 == n2 && z1 < z2)
}
fn donor_type(mol: &MolBuilder, idx: u32) -> Donor {
let atom = mol.atoms()[idx as usize];
if atom.atomic_num == 0 {
return if cyclic_multiple_bond(mol, idx) {
Donor::One
} else {
Donor::Any
};
}
let Some(nelec) = count_pi_electrons(mol, idx) else {
return Donor::None;
};
if nelec < 0 {
return Donor::None;
}
if nelec == 0 {
if exocyclic_multiple_bond(mol, idx).is_some() {
Donor::Vacant
} else if cyclic_multiple_bond(mol, idx) {
Donor::One
} else {
Donor::None
}
} else if nelec == 1 {
if let Some(other) = exocyclic_multiple_bond(mol, idx) {
let z_other = mol.atoms()[other as usize].atomic_num;
if more_electronegative(z_other, atom.atomic_num) {
Donor::Vacant
} else {
Donor::One
}
} else if has_multiple_bond(mol, idx) {
Donor::One
} else if atom.formal_charge == 1 {
Donor::Vacant
} else {
Donor::None
}
} else {
let mut nelec = nelec;
if let Some(other) = exocyclic_multiple_bond(mol, idx) {
let z_other = mol.atoms()[other as usize].atomic_num;
if more_electronegative(z_other, atom.atomic_num) {
nelec -= 1;
}
}
if nelec % 2 == 1 {
Donor::One
} else {
Donor::Two
}
}
}
fn is_arom_candidate(mol: &MolBuilder, idx: u32) -> bool {
let atom = mol.atoms()[idx as usize];
let z = atom.atomic_num;
if z > 18 && z != 34 && z != 52 {
return false;
}
let default_valence = |zz: u8| {
element::by_atomic_num(zz)
.and_then(|e| e.valences.first().copied())
.map_or(-1, i32::from)
};
let dv = default_valence(z);
if dv > 0 {
let eff_z = (i32::from(z) - i32::from(atom.formal_charge)).clamp(0, 118) as u8;
let total_valence = explicit_valence_nonstrict(mol, idx) + i32::from(total_hs(mol, idx))
- i32::from(atom.num_explicit_hs);
if total_valence > default_valence(eff_z) {
return false;
}
}
if atom.num_radical_electrons > 0 && (z != 6 || atom.formal_charge != 0) {
return false;
}
if unsaturations(mol, idx) > 1 {
let n_mult = mol
.neighbors(idx)
.filter(|&(_, bi)| {
matches!(
mol.bonds()[bi as usize].order,
BondOrder::Double | BondOrder::Triple
)
})
.count();
if n_mult > 1 {
return false;
}
}
true
}
fn ring_neighbors(rings: &[&Ring], n_bonds: usize) -> Vec<Vec<usize>> {
let mut bond_rings: Vec<Vec<u32>> = vec![Vec::new(); n_bonds];
for (i, r) in rings.iter().enumerate() {
if r.bonds.len() > MAX_FUSED_RING_SIZE {
continue;
}
for &b in &r.bonds {
bond_rings[b as usize].push(i as u32);
}
}
let mut shared: std::collections::HashMap<(u32, u32), u32> = std::collections::HashMap::new();
for rs in &bond_rings {
for a in 0..rs.len() {
for b in (a + 1)..rs.len() {
*shared.entry((rs[a], rs[b])).or_default() += 1;
}
}
}
let mut out = vec![Vec::new(); rings.len()];
for ((i, j), count) in shared {
if count == 1 {
out[i as usize].push(j as usize);
out[j as usize].push(i as usize);
}
}
for v in &mut out {
v.sort_unstable();
}
out
}
fn collect_group(start: usize, neigh: &[Vec<usize>], done: &mut [bool]) -> Vec<usize> {
let mut group = Vec::new();
let mut stack = vec![start];
done[start] = true;
while let Some(r) = stack.pop() {
group.push(r);
for &nb in &neigh[r] {
if !done[nb] {
done[nb] = true;
stack.push(nb);
}
}
}
group.sort_unstable();
group
}
struct ConnectedSubsets<'a> {
neigh: &'a [Vec<usize>],
max_size: usize,
cur: Vec<Vec<usize>>,
size: usize,
}
impl<'a> ConnectedSubsets<'a> {
fn new(neigh: &'a [Vec<usize>], max_size: usize) -> Self {
Self {
neigh,
max_size,
cur: Vec::new(),
size: 0,
}
}
fn next_level(&mut self) -> Option<&[Vec<usize>]> {
if self.size >= self.max_size {
return None;
}
self.size += 1;
if self.size == 1 {
self.cur = (0..self.neigh.len()).map(|i| vec![i]).collect();
return Some(&self.cur);
}
let mut next: std::collections::BTreeSet<Vec<usize>> = std::collections::BTreeSet::new();
for subset in &self.cur {
for &p in subset {
for &nb in &self.neigh[p] {
if subset.binary_search(&nb).is_ok() {
continue;
}
let mut t = Vec::with_capacity(subset.len() + 1);
t.extend_from_slice(subset);
t.push(nb);
t.sort_unstable();
next.insert(t);
}
}
}
if next.is_empty() {
return None;
}
self.cur = next.into_iter().collect();
Some(&self.cur)
}
}
fn huckel_on_group(
mol: &mut MolBuilder,
rings: &[&Ring],
group: &[usize],
neigh: &[Vec<usize>],
donors: &[Donor],
scratch: &mut Scratch,
) -> usize {
let local_neigh: Vec<Vec<usize>> = group
.iter()
.map(|&r| {
let mut v: Vec<usize> = neigh[r]
.iter()
.filter_map(|x| group.binary_search(x).ok())
.collect();
v.sort_unstable();
v
})
.collect();
let mut n_ring_bonds = 0usize;
for &r in group {
for &b in &rings[r].bonds {
if !scratch.bond_seen[b as usize] {
scratch.bond_seen[b as usize] = true;
n_ring_bonds += 1;
}
}
}
for &r in group {
for &b in &rings[r].bonds {
scratch.bond_seen[b as usize] = false;
}
}
let mut aromatic_rings: Vec<usize> = Vec::new();
let mut n_done_bonds = 0usize;
let mut touched: Vec<u32> = Vec::new();
let mut counted: Vec<u32> = Vec::new();
let mut levels = ConnectedSubsets::new(&local_neigh, MAX_FUSED_RINGS);
let mut subset: Vec<usize> = Vec::new();
while n_done_bonds < n_ring_bonds {
let Some(level) = levels.next_level() else {
break;
};
for positions in level {
subset.clear();
subset.extend(positions.iter().map(|&p| group[p]));
touched.clear();
for &r in &subset {
for &a in &rings[r].atoms {
if scratch.ring_count[a as usize] == 0 {
touched.push(a);
}
scratch.ring_count[a as usize] += 1;
}
}
counted.clear();
counted.extend(
touched
.iter()
.copied()
.filter(|&a| matches!(scratch.ring_count[a as usize], 1 | 2)),
);
let aromatic = huckel(&counted, donors);
for &a in &touched {
scratch.ring_count[a as usize] = 0;
}
if aromatic {
mark_aromatic(
mol,
rings,
&subset,
&mut scratch.marked_bonds,
&mut n_done_bonds,
);
aromatic_rings.extend_from_slice(&subset);
}
}
}
for &r in group {
for &b in &rings[r].bonds {
scratch.marked_bonds[b as usize] = false;
}
}
aromatic_rings.sort_unstable();
aromatic_rings.dedup();
aromatic_rings.len()
}
fn huckel(atoms: &[u32], donors: &[Donor]) -> bool {
let (mut low, mut up) = (0i32, 0i32);
let mut n_any = 0usize;
for &a in atoms {
let d = donors[a as usize];
if d == Donor::Any {
n_any += 1;
if n_any > 1 {
return false;
}
}
let (lo, hi) = d.range();
low += lo;
up += hi;
}
if up >= 6 {
(low..=up).any(|e| (e - 2) % 4 == 0)
} else {
up == 2
}
}
fn mark_aromatic(
mol: &mut MolBuilder,
rings: &[&Ring],
subset: &[usize],
marked_bonds: &mut [bool],
n_done_bonds: &mut usize,
) {
let mut count = std::collections::BTreeMap::<u32, usize>::new();
for &r in subset {
for &b in &rings[r].bonds {
*count.entry(b).or_default() += 1;
}
}
for (bi, c) in count {
if c != 1 {
continue;
}
let bond = mol.bonds()[bi as usize];
if !marked_bonds[bi as usize] {
marked_bonds[bi as usize] = true;
*n_done_bonds += 1;
}
if let Some(mut b) = mol.bond_mut(bi) {
b.flags_mut().insert(BondFlags::AROMATIC);
if matches!(bond.order, BondOrder::Single | BondOrder::Double) {
b.set_order(BondOrder::Aromatic);
} else {
continue;
}
}
for a in [bond.begin, bond.end] {
if let Some(at) = mol.atom_mut(a) {
at.flags.insert(AtomFlags::AROMATIC);
}
}
}
}
#[cfg(test)]
mod tests {
use omgkit_io::smiles;
use super::*;
use crate::{assign_radicals, clean_up, kekulize, perceive_rings, update_property_cache};
fn perceive(smi: &str) -> (Vec<bool>, Vec<bool>) {
let mut m = smiles::parse(smi).unwrap_or_else(|e| panic!("{}", e.render()));
clean_up(&mut m);
update_property_cache(&mut m).expect("价键校验应通过");
let _ = perceive_rings(&mut m);
kekulize(&mut m).expect("应能 kekulize");
assign_radicals(&mut m);
set_aromaticity(&mut m);
(
m.atoms()
.iter()
.map(|a| a.flags.contains(AtomFlags::AROMATIC))
.collect(),
m.bonds()
.iter()
.map(|b| b.flags.contains(BondFlags::AROMATIC))
.collect(),
)
}
fn n_aromatic_atoms(smi: &str) -> usize {
perceive(smi).0.iter().filter(|&&x| x).count()
}
#[test]
fn benzene_is_aromatic() {
let (a, b) = perceive("c1ccccc1");
assert!(a.iter().all(|&x| x), "苯的全部碳都应芳香");
assert!(b.iter().all(|&x| x), "苯的全部键都应芳香");
}
#[test]
fn kekule_input_is_perceived_as_aromatic() {
assert_eq!(n_aromatic_atoms("C1=CC=CC=C1"), 6);
assert_eq!(n_aromatic_atoms("C1=CC=NC=C1"), 6, "凯库勒式吡啶");
}
#[test]
fn classic_heteroaromatics() {
for (smi, n, name) in [
("c1ccncc1", 6, "吡啶"),
("c1cc[nH]c1", 5, "吡咯"),
("c1ccoc1", 5, "呋喃"),
("c1ccsc1", 5, "噻吩"),
("c1cnc[nH]1", 5, "咪唑"),
("c1ccc2ccccc2c1", 10, "萘"),
("c1ccc2[nH]ccc2c1", 9, "吲哚"),
] {
assert_eq!(n_aromatic_atoms(smi), n, "{name}");
}
}
#[test]
fn saturated_rings_are_not_aromatic() {
for smi in ["C1CCCCC1", "C1CCNCC1", "C1CO1", "C1CCC1"] {
assert_eq!(n_aromatic_atoms(smi), 0, "{smi}");
}
}
#[test]
fn antiaromatic_and_nonaromatic_rings() {
assert_eq!(n_aromatic_atoms("C1=CC=C1"), 0, "环丁二烯:4 电子");
assert_eq!(n_aromatic_atoms("C1=CCCCCCC1"), 0, "环辛烯");
assert_eq!(n_aromatic_atoms("O=C1C=CC(=O)C=C1"), 0, "对苯醌");
}
#[test]
fn cationic_aromatics() {
assert_eq!(n_aromatic_atoms("[cH+]1cc1"), 3, "环丙烯正离子");
assert_eq!(n_aromatic_atoms("[cH+]1cccccc1"), 7, "卓鎓离子");
}
#[test]
fn exocyclic_double_bonds_steal_electrons() {
assert_eq!(n_aromatic_atoms("O=C1CCCCC1"), 0);
assert_eq!(n_aromatic_atoms("O=C1C=CC=C1"), 0);
}
#[test]
fn fused_systems_mark_all_bonds() {
for (smi, name) in [
("c1ccc2ccccc2c1", "萘"),
("c1ccc2c(c1)ccc1ccccc12", "菲"),
("c1cc2ccc3cccc4ccc(c1)c2c34", "芘"),
] {
let (a, b) = perceive(smi);
assert!(a.iter().all(|&x| x), "{name}: 应全部原子芳香");
assert!(b.iter().all(|&x| x), "{name}: 应全部键芳香,含融合键");
}
}
#[test]
fn partially_aromatic_molecules() {
let (a, _) = perceive("c1ccccc1C1CCCCC1");
assert_eq!(a.iter().filter(|&&x| x).count(), 6, "只有苯环芳香");
assert!(a[..6].iter().all(|&x| x));
assert!(a[6..].iter().all(|&x| !x));
}
#[test]
fn is_idempotent() {
for smi in ["c1ccccc1", "c1ccc2ccccc2c1", "c1cc[nH]c1", "C1CCCCC1"] {
let mut m = smiles::parse(smi).unwrap();
clean_up(&mut m);
update_property_cache(&mut m).unwrap();
let _ = perceive_rings(&mut m);
kekulize(&mut m).unwrap();
assign_radicals(&mut m);
set_aromaticity(&mut m);
let once: Vec<_> = m.atoms().iter().map(|a| a.flags).collect();
set_aromaticity(&mut m);
let twice: Vec<_> = m.atoms().iter().map(|a| a.flags).collect();
assert_eq!(once, twice, "{smi}: 不幂等");
}
}
#[test]
fn acyclic_molecules_are_untouched() {
for smi in ["CCO", "C=CC=C", "N#CC=O"] {
assert_eq!(n_aromatic_atoms(smi), 0, "{smi}");
}
}
}