use omgkit_core::{element, AtomFlags, MolBuilder};
pub fn assign_radicals(mol: &mut MolBuilder) {
for i in 0..mol.num_atoms() as u32 {
if let Some(n) = radicals_of(mol, i) {
if let Some(a) = mol.atom_mut(i) {
a.num_radical_electrons = n;
}
}
}
}
fn radicals_of(mol: &MolBuilder, idx: u32) -> Option<u8> {
let atom = mol.atoms()[idx as usize];
let z = atom.atomic_num;
if !atom.flags.contains(AtomFlags::NO_IMPLICIT) || z == 0 {
return None;
}
let valences = element::by_atomic_num(z).map_or(&[-1i8][..], |e| e.valences);
let n_outer = i32::from(element::by_atomic_num(z).map_or(0, |e| e.outer_electrons));
let chg = i32::from(atom.formal_charge);
let constrained = valences.len() != 1 || valences[0] != -1;
if !constrained {
if mol.degree(idx) > 0 {
return Some(0);
}
let n_valence = (n_outer - chg).max(0);
return Some((n_valence % 2) as u8);
}
let accum: f32 = mol
.neighbors(idx)
.map(|(_, bi)| mol.bonds()[bi as usize].valence_contribution_to(idx))
.sum::<f32>()
+ f32::from(atom.num_explicit_hs);
let total_valence = (accum + 0.1) as i32;
let base_count = if matches!(z, 1 | 2) { 2 } else { 8 };
let mut n_radicals = base_count - n_outer - total_valence + chg;
if n_radicals < 0 {
n_radicals = 0;
if valences.len() > 1 {
for &val in valences {
let val = i32::from(val);
if val - total_valence + chg >= 0 {
n_radicals = val - total_valence + chg;
break;
}
}
}
}
let n_radicals2 = n_outer - total_valence - chg;
if n_radicals2 >= 0 {
n_radicals = n_radicals.min(n_radicals2);
}
u8::try_from(n_radicals.max(0)).ok()
}
#[cfg(test)]
mod tests {
use omgkit_io::smiles;
use super::*;
use crate::{clean_up, kekulize, perceive_rings, update_property_cache};
fn radicals(smi: &str) -> Vec<u8> {
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);
m.atoms().iter().map(|a| a.num_radical_electrons).collect()
}
#[test]
fn atoms_without_brackets_never_get_radicals() {
for smi in ["CCO", "c1ccccc1", "CC(=O)O", "C1CCCCC1", "N#Cc1ccccc1"] {
assert!(
radicals(smi).iter().all(|&r| r == 0),
"{smi}: 非方括号原子不应有自由基"
);
}
}
#[test]
fn classic_radicals() {
assert_eq!(radicals("[CH3]"), vec![1], "甲基自由基:一个未成对电子");
assert_eq!(radicals("[CH2]"), vec![2], "亚甲基:两个");
assert_eq!(radicals("[CH]"), vec![3]);
assert_eq!(radicals("[C]"), vec![4]);
assert_eq!(radicals("[OH]"), vec![1], "羟基自由基");
assert_eq!(radicals("[NH2]"), vec![1], "氨基自由基");
}
#[test]
fn pyrrolyl_nitrogen_is_a_radical() {
let r = radicals("[N]1C=CC=C1");
assert_eq!(r[0], 1, "N 应带一个自由基电子,实际 {r:?}");
assert!(r[1..].iter().all(|&x| x == 0), "碳不应带自由基");
}
#[test]
fn saturated_bracket_atoms_have_none() {
assert_eq!(radicals("[CH4]"), vec![0]);
assert_eq!(radicals("[NH3]"), vec![0]);
assert_eq!(radicals("[OH2]"), vec![0]);
assert_eq!(radicals("[NH4+]"), vec![0]);
assert_eq!(radicals("[OH-]"), vec![0]);
}
#[test]
fn charge_enters_the_formula_not_the_lookup() {
assert_eq!(radicals("[CH3+]"), vec![0], "甲基正离子:六电子,无未成对");
assert_eq!(radicals("[CH3-]"), vec![0], "甲基负离子:孤对,非自由基");
assert_eq!(radicals("[NH2-]"), vec![0]);
assert_eq!(radicals("[NH3+]"), vec![1], "氨自由基阳离子");
}
#[test]
fn dummy_atoms_are_left_alone() {
assert_eq!(radicals("[*]"), vec![0]);
assert_eq!(radicals("C[*]"), vec![0, 0]);
}
#[test]
fn unconstrained_elements() {
assert_eq!(radicals("[Fe]"), vec![0], "Fe 外层 8 个电子,偶数");
assert_eq!(radicals("[Cu]"), vec![1], "Cu 外层 11 个电子,奇数");
assert_eq!(radicals("[Cu]Cl")[0], 0, "一旦成键就不赋自由基");
}
#[test]
fn is_idempotent() {
for smi in ["[CH3]", "[N]1C=CC=C1", "CCO", "[Fe]", "[OH]"] {
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);
let once: Vec<u8> = m.atoms().iter().map(|a| a.num_radical_electrons).collect();
assign_radicals(&mut m);
let twice: Vec<u8> = m.atoms().iter().map(|a| a.num_radical_electrons).collect();
assert_eq!(once, twice, "{smi}: 不幂等");
}
}
}