use omgkit_core::{AtomData, BondData, BondOrder, ChiralTag, MolBuilder};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Augmented {
pub edited: Vec<(u32, AtomData)>,
pub atoms: Vec<AtomData>,
pub bonds: Vec<BondData>,
}
impl Augmented {
#[must_use]
pub fn is_empty(&self) -> bool {
self.atoms.is_empty()
}
#[must_use]
pub fn apply(&self, mol: &MolBuilder) -> MolBuilder {
let mut out = mol.clone();
for (a, data) in &self.edited {
if let Some(x) = out.atom_mut(*a) {
*x = *data;
}
}
for a in &self.atoms {
out.add_atom_data(*a);
}
for b in &self.bonds {
out.add_bond_data(*b).expect("补出来的键端点越界");
}
out
}
}
fn needs_h(mol: &MolBuilder, a: u32, rings: &[omgkit_chem::sssr::Ring]) -> bool {
let at = mol.atoms()[a as usize];
if !matches!(at.chiral_tag, ChiralTag::Cw | ChiralTag::Ccw) {
return false;
}
if u32::from(at.num_explicit_hs) + u32::from(at.num_implicit_hs) == 0 {
return false; }
if mol.degree(a) < 3 {
return true;
}
!mol.neighbors(a).any(|(_, b)| {
mol.bonds()[b as usize].order == BondOrder::Single
&& !rings.iter().any(|r| r.bonds.contains(&b))
})
}
#[must_use]
pub fn with_stereo_hs(mol: &MolBuilder) -> Option<Augmented> {
let rings = omgkit_chem::sssr::ring_set(mol);
let genuine = omgkit_io::stereo::genuine_tetrahedral(mol);
let mut centres: Vec<u32> = (0..u32::try_from(mol.num_atoms()).expect("原子数超出 u32"))
.filter(|a| genuine[*a as usize] && needs_h(mol, *a, &rings))
.collect();
if centres.is_empty() {
return None;
}
let ranks = crate::ranks_of(mol);
centres.sort_by_key(|a| (ranks[*a as usize], *a));
let mut out = Augmented::default();
let first = u32::try_from(mol.num_atoms()).expect("原子数超出 u32");
for (next, a) in (first..).zip(centres) {
let mut at = mol.atoms()[a as usize];
if at.num_explicit_hs > 0 {
at.num_explicit_hs -= 1;
} else {
at.num_implicit_hs -= 1;
}
out.edited.push((a, at));
out.atoms.push(AtomData {
atomic_num: 1,
..AtomData::default()
});
out.bonds.push(BondData::new(a, next, BondOrder::Single));
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn shuffled(n: usize, seed: u64) -> Vec<u32> {
let mut s = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut next = || {
s = s.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = s;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
};
let mut v: Vec<u32> = (0..u32::try_from(n).unwrap()).collect();
for i in (1..n).rev() {
let j = (next() % (i as u64 + 1)) as usize;
v.swap(i, j);
}
v
}
fn prep(smi: &str) -> MolBuilder {
let mut m = omgkit_io::smiles::parse(smi).unwrap();
omgkit_chem::pipeline::sanitize(&mut m).unwrap();
m
}
const STEROID: &str =
"CC(C)CCC[C@@H](C)[C@H]1CC[C@H]2[C@@H]3CC=C4C[C@@H](O)CC[C@]4(C)[C@H]3CC[C@]12C";
#[test]
fn a_molecule_that_needs_nothing_gets_nothing() {
for smi in ["CC(=O)Oc1ccccc1C(=O)O", "C[C@H](N)C(=O)O", "c1ccccc1"] {
assert!(
with_stereo_hs(&prep(smi)).is_none(),
"{smi} 不需要补氢,却补了"
);
}
}
#[test]
fn a_fused_ring_stereocentre_gets_one() {
let m = prep(STEROID);
let aug = with_stereo_hs(&m).expect("甾体该补氢");
assert_eq!(aug.atoms.len(), 3, "甾体该补三个:C8/C9/C14");
assert_eq!(aug.bonds.len(), aug.atoms.len());
for (a, _) in &aug.edited {
let rings = omgkit_chem::sssr::ring_set(&m);
assert!(
m.neighbors(*a)
.all(|(_, b)| rings.iter().any(|r| r.bonds.contains(&b))),
"补氢的中心 {a} 有环外键可用,本不该补"
);
}
}
#[test]
fn the_original_numbering_survives() {
let m = prep(STEROID);
let aug = with_stereo_hs(&m).expect("甾体该补氢");
let m2 = aug.apply(&m);
assert_eq!(m2.num_atoms(), m.num_atoms() + aug.atoms.len());
assert_eq!(m2.num_bonds(), m.num_bonds() + aug.bonds.len());
let edited: std::collections::BTreeMap<u32, AtomData> =
aug.edited.iter().copied().collect();
for i in 0..m.num_atoms() {
let a = u32::try_from(i).unwrap();
let want = edited.get(&a).copied().unwrap_or(m.atoms()[i]);
assert_eq!(m2.atoms()[i], want, "原子 {i} 的数据变了");
}
for i in 0..m.num_bonds() {
assert_eq!(m2.bonds()[i], m.bonds()[i], "键 {i} 的数据变了");
}
for (k, b) in aug.bonds.iter().enumerate() {
let last = m2.neighbors(b.begin).last().expect("中心有邻居");
assert_eq!(
last.0, b.end,
"第 {k} 个补出来的氢不是中心 {} 的最后一个邻居",
b.begin
);
}
}
#[test]
fn the_augmented_molecule_still_sanitises() {
let m = prep(STEROID);
let mut m2 = with_stereo_hs(&m).expect("甾体该补氢").apply(&m);
omgkit_chem::pipeline::sanitize(&mut m2).expect("补完氢还得是个合法分子");
let total = |x: &MolBuilder| -> usize {
x.atoms()
.iter()
.map(|a| {
usize::from(a.num_explicit_hs)
+ usize::from(a.num_implicit_hs)
+ usize::from(a.atomic_num == 1)
})
.sum()
};
assert_eq!(total(&m2), total(&m), "补氢前后总氢数不一样");
}
#[test]
fn which_centres_get_hs_does_not_depend_on_how_it_was_written() {
let m = prep(STEROID);
let ranks = omgkit_io::canon::canonical_ranks(&m);
let aug = with_stereo_hs(&m).expect("甾体该补氢");
let want: Vec<u32> = aug.edited.iter().map(|(a, _)| ranks[*a as usize]).collect();
let n = m.num_atoms();
let mut compared = 0usize;
for seed in 0..24u64 {
let priority = shuffled(n, seed);
let w = omgkit_io::smiles::write_with_priority(&m, &priority);
let Ok(mut m2) = omgkit_io::smiles::parse(&w.smiles) else {
continue;
};
if omgkit_chem::pipeline::sanitize(&mut m2).is_err() {
continue;
}
if omgkit_io::canon::canonical_smiles(&m2).smiles
!= omgkit_io::canon::canonical_smiles(&m).smiles
{
continue; }
let r2 = omgkit_io::canon::canonical_ranks(&m2);
let aug2 = with_stereo_hs(&m2).expect("同一个分子,照样该补氢");
let got: Vec<u32> = aug2.edited.iter().map(|(a, _)| r2[*a as usize]).collect();
assert_eq!(got, want, "换成 {} 之后补氢的中心变了", w.smiles);
compared += 1;
}
assert!(compared > 0, "一次都没比成 —— 这条判据是空过的");
}
}