use std::collections::{BTreeMap, BTreeSet};
use omgkit_core::{
BondData, BondDirection, BondFlags, BondOrder, BondStereo, ChiralTag, MolBuilder,
};
use crate::wedge::Wedge;
use crate::canon::symmetry_classes;
#[must_use]
pub fn genuine_tetrahedral(mol: &MolBuilder) -> Vec<bool> {
let n = mol.num_atoms();
let mut out = vec![false; n];
if n == 0 {
return out;
}
let classes = symmetry_classes(mol);
let tagged: Vec<bool> = mol
.atoms()
.iter()
.map(|a| a.chiral_tag.is_tetrahedral())
.collect();
for a in 0..n as u32 {
if !tagged[a as usize] {
continue;
}
out[a as usize] = match equivalent_neighbour_pair(mol, a, &classes) {
None => true,
Some((x, y)) => branch_has_other_stereocentre(mol, a, x, y, &tagged),
};
}
out
}
const MIN_STEREOGENIC_RING: usize = 8;
fn in_small_ring(mol: &MolBuilder, bond: u32) -> bool {
let Some(&b) = mol.bonds().get(bond as usize) else {
return false;
};
let mut seen: BTreeSet<u32> = [b.begin].into_iter().collect();
let mut frontier = vec![b.begin];
for _ in 0..MIN_STEREOGENIC_RING - 2 {
let mut next = Vec::new();
for &cur in &frontier {
for (other, bi) in mol.neighbors(cur) {
if bi == bond {
continue;
}
if other == b.end {
return true;
}
if seen.insert(other) {
next.push(other);
}
}
}
if next.is_empty() {
return false;
}
frontier = next;
}
false
}
#[must_use]
pub fn raw_cis_trans(mol: &MolBuilder, bond: u32) -> Option<(BondStereo, [u32; 2])> {
let db = *mol.bonds().get(bond as usize)?;
if db.order != BondOrder::Double || in_small_ring(mol, bond) {
return None;
}
let (ra, da) = raw_outward(mol, db.begin, db.end)?;
let (rb, dbi) = raw_outward(mol, db.end, db.begin)?;
let stereo = if da == dbi {
BondStereo::Cis
} else {
BondStereo::Trans
};
Some((stereo, [ra, rb]))
}
#[must_use]
pub fn directions_not_perceived(mol: &MolBuilder) -> bool {
let informative = informative_directions(mol);
if !informative.iter().any(|&x| x) {
return false;
}
(0..mol.num_bonds()).any(|i| {
mol.bonds()[i].stereo == BondStereo::None && would_annotate(mol, i, &informative).is_some()
})
}
fn would_annotate(
mol: &MolBuilder,
di: usize,
informative: &[bool],
) -> Option<(BondStereo, [u32; 2])> {
let db = *mol.bonds().get(di)?;
if db.order != BondOrder::Double || db.flags.contains(BondFlags::AROMATIC) {
return None;
}
if in_small_ring(mol, u32::try_from(di).ok()?) {
return None;
}
if stereo_atoms_are_valid(mol, u32::try_from(di).ok()?) {
return None;
}
let (ref_b, dir_b) = outward_direction(mol, db.begin, db.end, informative)?;
let (ref_e, dir_e) = outward_direction(mol, db.end, db.begin, informative)?;
let stereo = if dir_b == dir_e {
BondStereo::Cis
} else {
BondStereo::Trans
};
Some((stereo, [ref_b, ref_e]))
}
fn raw_outward(mol: &MolBuilder, end: u32, other: u32) -> Option<(u32, BondDirection)> {
mol.neighbors(end)
.filter(|&(o, _)| o != other)
.find(|&(_, bi)| mol.bonds()[bi as usize].direction != BondDirection::None)
.map(|(o, bi)| {
let b = mol.bonds()[bi as usize];
let d = if b.begin == end {
b.direction
} else {
b.direction.flipped()
};
(o, d)
})
}
#[must_use]
pub fn informative_directions(mol: &MolBuilder) -> Vec<bool> {
let mut out = vec![false; mol.num_bonds()];
if !mol
.bonds()
.iter()
.any(|b| b.direction != BondDirection::None)
{
return out;
}
let classes = symmetry_classes(mol);
for (di, db) in mol.bonds().iter().enumerate() {
if db.order != BondOrder::Double || db.flags.contains(BondFlags::AROMATIC) {
continue;
}
if in_small_ring(mol, u32::try_from(di).unwrap_or(u32::MAX)) {
continue;
}
if !end_is_stereogenic(mol, db.begin, db.end, &classes)
|| !end_is_stereogenic(mol, db.end, db.begin, &classes)
{
continue;
}
let left = directional_bonds_at(mol, db.begin, db.end);
let right = directional_bonds_at(mol, db.end, db.begin);
if left.is_empty() || right.is_empty() {
continue;
}
for bi in left.into_iter().chain(right) {
out[bi as usize] = true;
}
}
out
}
fn end_is_stereogenic(mol: &MolBuilder, end: u32, other: u32, classes: &[u32]) -> bool {
let subs: Vec<u32> = mol
.neighbors(end)
.map(|(o, _)| o)
.filter(|&o| o != other)
.collect();
match subs.len() {
0 | 1 => true,
2 => classes[subs[0] as usize] != classes[subs[1] as usize],
_ => false,
}
}
fn reference_neighbours(mol: &MolBuilder, end: u32, other: u32) -> Vec<(u32, u32)> {
mol.neighbors(end)
.filter(|&(o, _)| o != other)
.filter(|&(_, bi)| mol.bonds()[bi as usize].order != BondOrder::Double)
.collect()
}
fn directional_bonds_at(mol: &MolBuilder, end: u32, other: u32) -> Vec<u32> {
reference_neighbours(mol, end, other)
.into_iter()
.filter(|&(_, bi)| mol.bonds()[bi as usize].direction != BondDirection::None)
.map(|(_, bi)| bi)
.collect()
}
fn equivalent_neighbour_pair(mol: &MolBuilder, a: u32, classes: &[u32]) -> Option<(u32, u32)> {
let nbrs: Vec<u32> = mol.neighbors(a).map(|(other, _)| other).collect();
for i in 0..nbrs.len() {
for j in i + 1..nbrs.len() {
if classes[nbrs[i] as usize] == classes[nbrs[j] as usize] {
return Some((nbrs[i], nbrs[j]));
}
}
}
None
}
fn branch_has_other_stereocentre(
mol: &MolBuilder,
a: u32,
x: u32,
y: u32,
tagged: &[bool],
) -> bool {
let mut seen: BTreeSet<u32> = [a].into_iter().collect();
let mut stack = vec![x, y];
seen.insert(x);
seen.insert(y);
while let Some(cur) = stack.pop() {
if cur != a && tagged[cur as usize] {
return true;
}
for (other, _) in mol.neighbors(cur) {
if seen.insert(other) {
stack.push(other);
}
}
}
false
}
pub fn perceive_bond_stereo(mol: &mut MolBuilder) -> usize {
let informative = informative_directions(mol);
if !informative.iter().any(|&x| x) {
return 0;
}
let mut found: Vec<(u32, BondStereo, [u32; 2])> = Vec::new();
for di in 0..mol.num_bonds() {
if let Some((stereo, atoms)) = would_annotate(mol, di, &informative) {
found.push((u32::try_from(di).unwrap_or(u32::MAX), stereo, atoms));
}
}
let n = found.len();
for (di, stereo, atoms) in found {
if let Some(mut b) = mol.bond_mut(di) {
b.set_stereo(stereo);
b.set_stereo_atoms(atoms);
}
}
n
}
fn outward_direction(
mol: &MolBuilder,
end: u32,
other: u32,
informative: &[bool],
) -> Option<(u32, BondDirection)> {
mol.neighbors(end)
.filter(|&(o, _)| o != other)
.find(|&(_, bi)| informative[bi as usize])
.map(|(o, bi)| {
let b = mol.bonds()[bi as usize];
let dir = if b.begin == end {
b.direction
} else {
b.direction.flipped()
};
(o, dir)
})
}
#[must_use]
pub fn assign_chirality_2d(mol: &mut MolBuilder, coords: &[[f64; 3]], wedges: &[Wedge]) -> usize {
if coords.iter().any(|p| p[2].abs() > FLAT_TOL) {
return 0;
}
let mut n = 0;
for a in 0..u32::try_from(mol.num_atoms()).unwrap_or(0) {
let Some(tag) = crate::wedge::chirality_from_wedges(mol, coords, wedges, a) else {
continue;
};
if let Some(at) = mol.atom_mut(a) {
at.chiral_tag = tag;
n += 1;
}
}
n
}
#[must_use]
pub fn assign_chirality_3d(mol: &mut MolBuilder, coords: &[[f64; 3]]) -> usize {
if !coords.iter().any(|p| p[2].abs() > FLAT_TOL) {
return 0;
}
let classes = symmetry_classes(mol);
let na = u32::try_from(mol.num_atoms()).unwrap_or(0);
let from_geometry: Vec<Option<ChiralTag>> = (0..na)
.map(|a| chirality_from_coords(mol, coords, a))
.collect();
let mut tagged: Vec<bool> = from_geometry.iter().map(Option::is_some).collect();
let mut shaky: Vec<(u32, u32, u32)> = (0..na)
.filter(|&a| tagged[a as usize])
.filter_map(|a| equivalent_neighbour_pair(mol, a, &classes).map(|(x, y)| (a, x, y)))
.collect();
loop {
let before = shaky.len();
let mut doomed = Vec::new();
shaky.retain(|&(a, x, y)| {
if branch_has_other_stereocentre(mol, a, x, y, &tagged) {
true
} else {
doomed.push(a);
false
}
});
for a in doomed {
tagged[a as usize] = false;
}
if shaky.len() == before {
break;
}
}
let mut n = 0;
for a in 0..na {
if !tagged[a as usize] {
continue;
}
let Some(tag) = from_geometry[a as usize] else {
continue;
};
if let Some(at) = mol.atom_mut(a) {
at.chiral_tag = tag;
n += 1;
}
}
n
}
fn chirality_from_coords(mol: &MolBuilder, coords: &[[f64; 3]], a: u32) -> Option<ChiralTag> {
let nbrs: Vec<u32> = mol.neighbors(a).map(|(n, _)| n).collect();
let hs = crate::wedge::total_hs(mol, a);
match (nbrs.len(), hs) {
(4, 0) => {}
(3, 1) => {}
(3, 0) if crate::wedge::has_lone_pair(mol, a) => {}
_ => return None,
}
let c = *coords.get(a as usize)?;
let mut dirs = [[0.0_f64; 3]; 3];
for (k, d) in dirs.iter_mut().enumerate() {
let p = *coords.get(*nbrs.get(k)? as usize)?;
*d = [p[0] - c[0], p[1] - c[1], p[2] - c[2]];
}
let (u, v, w) = (dirs[0], dirs[1], dirs[2]);
let vol = u[0] * (v[1] * w[2] - v[2] * w[1]) - u[1] * (v[0] * w[2] - v[2] * w[0])
+ u[2] * (v[0] * w[1] - v[1] * w[0]);
if vol.abs() <= crate::wedge::ZERO_VOLUME_TOL {
return None;
}
Some(if vol > 0.0 {
ChiralTag::Ccw
} else {
ChiralTag::Cw
})
}
const AXIS_TOL: f64 = 1e-9;
pub(crate) const FLAT_TOL: f64 = 1e-9;
#[must_use]
pub fn cis_trans_from_points(
begin: [f64; 2],
end: [f64; 2],
ref_begin: [f64; 2],
ref_end: [f64; 2],
) -> Option<BondStereo> {
let (dx, dy) = (end[0] - begin[0], end[1] - begin[1]);
let side = |p: [f64; 2]| dx * (p[1] - begin[1]) - dy * (p[0] - begin[0]);
let (sa, sb) = (side(ref_begin), side(ref_end));
if sa.abs() < AXIS_TOL || sb.abs() < AXIS_TOL {
return None;
}
Some(if sa * sb > 0.0 {
BondStereo::Cis
} else {
BondStereo::Trans
})
}
fn stereo_candidate(
mol: &MolBuilder,
unknown: &[bool],
di: u32,
classes: &[u32],
) -> Option<[u32; 2]> {
let db = *mol.bonds().get(di as usize)?;
if db.order != BondOrder::Double || db.flags.contains(BondFlags::AROMATIC) {
return None;
}
if in_small_ring(mol, di) {
return None;
}
if stereo_atoms_are_valid(mol, di) {
return None;
}
if !end_is_stereogenic(mol, db.begin, db.end, classes)
|| !end_is_stereogenic(mol, db.end, db.begin, classes)
{
return None;
}
let unsure = |bi: u32| unknown.get(bi as usize).copied().unwrap_or(true);
if unsure(di) {
return None;
}
let first = |end: u32, other: u32| {
reference_neighbours(mol, end, other)
.into_iter()
.find(|&(_, bi)| !unsure(bi))
.map(|(o, _)| o)
};
Some([first(db.begin, db.end)?, first(db.end, db.begin)?])
}
fn assign_bond_stereo(
mol: &mut MolBuilder,
unknown: &[bool],
geometry: impl Fn(u32, u32, u32, u32) -> Option<BondStereo>,
) -> usize {
let classes = symmetry_classes(mol);
let found: Vec<(u32, BondStereo, [u32; 2])> = (0..mol.num_bonds())
.filter_map(|di| {
let di = u32::try_from(di).ok()?;
let [ra, rb] = stereo_candidate(mol, unknown, di, &classes)?;
let db = *mol.bonds().get(di as usize)?;
let stereo = geometry(db.begin, db.end, ra, rb)?;
Some((di, stereo, [ra, rb]))
})
.collect();
let n = found.len();
for (di, stereo, atoms) in found {
if let Some(mut b) = mol.bond_mut(di) {
b.set_stereo(stereo);
b.set_stereo_atoms(atoms);
}
}
n
}
pub fn assign_bond_stereo_2d(mol: &mut MolBuilder, coords: &[[f64; 3]], unknown: &[bool]) -> usize {
if coords.iter().any(|p| p[2].abs() > FLAT_TOL) {
return 0;
}
let xy = |a: u32| coords.get(a as usize).map(|p| [p[0], p[1]]);
assign_bond_stereo(mol, unknown, |b, e, ra, rb| {
cis_trans_from_points(xy(b)?, xy(e)?, xy(ra)?, xy(rb)?)
})
}
#[must_use]
pub fn cis_trans_from_torsion(
begin: [f64; 3],
end: [f64; 3],
ref_begin: [f64; 3],
ref_end: [f64; 3],
) -> Option<BondStereo> {
let sub = |p: [f64; 3], q: [f64; 3]| [p[0] - q[0], p[1] - q[1], p[2] - q[2]];
let dot = |p: [f64; 3], q: [f64; 3]| p[0] * q[0] + p[1] * q[1] + p[2] * q[2];
let axis = sub(end, begin);
let n2 = dot(axis, axis);
if n2 < FLAT_TOL {
return None; }
let perp = |v: [f64; 3]| {
let k = dot(v, axis) / n2;
[v[0] - k * axis[0], v[1] - k * axis[1], v[2] - k * axis[2]]
};
let (pa, pb) = (perp(sub(ref_begin, begin)), perp(sub(ref_end, end)));
let (la, lb) = (dot(pa, pa).sqrt(), dot(pb, pb).sqrt());
if la < FLAT_TOL || lb < FLAT_TOL {
return None; }
let cos = dot(pa, pb) / (la * lb);
if cos.abs() < FLAT_TOL {
return None;
}
Some(if cos > 0.0 {
BondStereo::Cis
} else {
BondStereo::Trans
})
}
pub fn assign_bond_stereo_3d(mol: &mut MolBuilder, coords: &[[f64; 3]], unknown: &[bool]) -> usize {
if !coords.iter().any(|p| p[2].abs() > FLAT_TOL) {
return 0;
}
let at = |a: u32| coords.get(a as usize).copied();
assign_bond_stereo(mol, unknown, |b, e, ra, rb| {
cis_trans_from_torsion(at(b)?, at(e)?, at(ra)?, at(rb)?)
})
}
#[must_use]
pub fn stereo_atoms_are_valid(mol: &MolBuilder, bond: u32) -> bool {
let Some(&b) = mol.bonds().get(bond as usize) else {
return false;
};
if b.stereo == BondStereo::None {
return false;
}
let [ra, rb] = b.stereo_atoms;
if ra == BondData::NO_STEREO_ATOM || rb == BondData::NO_STEREO_ATOM {
return false;
}
mol.neighbors(b.begin).any(|(o, _)| o == ra) && mol.neighbors(b.end).any(|(o, _)| o == rb)
}
#[must_use]
pub fn directions_for_writing(mol: &MolBuilder) -> WritingDirections {
let informative = informative_directions(mol);
let mut out: Vec<BondDirection> = (0..mol.num_bonds())
.map(|i| {
if informative[i] {
mol.bonds()[i].direction
} else {
BondDirection::None
}
})
.collect();
let mut constraints: Vec<(Ref, Ref, BondStereo)> = Vec::new();
for di in 0..mol.num_bonds() as u32 {
if !stereo_atoms_are_valid(mol, di) {
continue;
}
let db = mol.bonds()[di as usize];
let (Some(b1), Some(b2)) = (
bond_between(mol, db.begin, db.stereo_atoms[0]),
bond_between(mol, db.end, db.stereo_atoms[1]),
) else {
continue;
};
constraints.push((
Ref {
bond: b1,
anchor: db.begin,
},
Ref {
bond: b2,
anchor: db.end,
},
db.stereo,
));
for end in [db.begin, db.end] {
for (_, bi) in mol.neighbors(end) {
if bi != di {
out[bi as usize] = BondDirection::None;
}
}
}
}
let mut adj: BTreeMap<u32, Vec<(Ref, Ref, bool)>> = BTreeMap::new();
for &(r1, r2, stereo) in &constraints {
let same = stereo == BondStereo::Cis;
adj.entry(r1.bond).or_default().push((r1, r2, same));
adj.entry(r2.bond).or_default().push((r2, r1, same));
}
let refbonds: BTreeSet<u32> = constraints
.iter()
.flat_map(|&(r1, r2, _)| [r1.bond, r2.bond])
.collect();
for di in 0..mol.num_bonds() as u32 {
if !stereo_atoms_are_valid(mol, di) {
continue;
}
let db = mol.bonds()[di as usize];
for end in [db.begin, db.end] {
let here: Vec<Ref> = mol
.neighbors(end)
.filter(|&(_, bi)| bi != di && refbonds.contains(&bi))
.map(|(_, bi)| Ref {
bond: bi,
anchor: end,
})
.collect();
for i in 0..here.len() {
for j in i + 1..here.len() {
let (a, b) = (here[i], here[j]);
adj.entry(a.bond).or_default().push((a, b, false));
adj.entry(b.bond).or_default().push((b, a, false));
}
}
}
}
let mut assigned: BTreeMap<u32, BondDirection> = BTreeMap::new();
let outward = |mol: &MolBuilder, r: Ref, stored: BondDirection| {
if mol.bonds()[r.bond as usize].begin == r.anchor {
stored
} else {
stored.flipped()
}
};
let mut component = vec![None; mol.num_bonds()];
let keys: Vec<u32> = adj.keys().copied().collect();
let mut next_comp = 0u32;
for start in keys {
if assigned.contains_key(&start) {
continue;
}
let comp = next_comp;
next_comp += 1;
assigned.insert(start, BondDirection::UpRight);
component[start as usize] = Some(comp);
let mut queue = vec![start];
while let Some(cur) = queue.pop() {
let cur_stored = assigned[&cur];
for &(from, to, same) in &adj[&cur] {
if assigned.contains_key(&to.bond) {
continue;
}
let out_from = outward(mol, from, cur_stored);
let out_to = if same { out_from } else { out_from.flipped() };
assigned.insert(to.bond, outward(mol, to, out_to));
component[to.bond as usize] = Some(comp);
queue.push(to.bond);
}
}
}
for (&bi, &dir) in &assigned {
out[bi as usize] = dir;
}
let mut stack: Vec<u32> = Vec::new();
for bi in 0..mol.num_bonds() as u32 {
if out[bi as usize] == BondDirection::None || component[bi as usize].is_some() {
continue;
}
let comp = next_comp;
next_comp += 1;
component[bi as usize] = Some(comp);
stack.push(bi);
while let Some(cur) = stack.pop() {
for other in flanking_directions(mol, cur, &out) {
if component[other as usize].is_none() {
component[other as usize] = Some(comp);
stack.push(other);
}
}
}
}
WritingDirections {
dirs: out,
component,
}
}
fn flanking_directions(mol: &MolBuilder, bond: u32, dirs: &[BondDirection]) -> Vec<u32> {
let b = mol.bonds()[bond as usize];
let mut out = Vec::new();
for end in [b.begin, b.end] {
for (far, di) in mol.neighbors(end) {
if di == bond || mol.bonds()[di as usize].order != BondOrder::Double {
continue;
}
for (_, oi) in mol.neighbors(far) {
if oi != di && dirs[oi as usize] != BondDirection::None {
out.push(oi);
}
}
}
}
out
}
pub struct WritingDirections {
pub dirs: Vec<BondDirection>,
pub component: Vec<Option<u32>>,
}
#[derive(Clone, Copy)]
struct Ref {
bond: u32,
anchor: u32,
}
fn bond_between(mol: &MolBuilder, a: u32, b: u32) -> Option<u32> {
mol.neighbors(a).find(|&(o, _)| o == b).map(|(_, bi)| bi)
}
#[must_use]
pub fn normalized_stereo_refs(mol: &MolBuilder, priority: &[u32]) -> Option<MolBuilder> {
let targets: Vec<u32> = (0..mol.num_bonds() as u32)
.filter(|&di| {
stereo_atoms_are_valid(mol, di)
&& matches!(
mol.bonds()[di as usize].stereo,
BondStereo::Cis | BondStereo::Trans
)
})
.collect();
if targets.is_empty() || priority.len() != mol.num_atoms() {
return None;
}
let mut out = mol.clone();
for di in targets {
let db = mol.bonds()[di as usize];
let pick = |end: u32, partner: u32| {
let key = |other: &u32| {
let h = u8::from(mol.atoms()[*other as usize].atomic_num == 1);
(h, priority[*other as usize])
};
mol.neighbors(end)
.map(|(other, _)| other)
.filter(|&other| other != partner)
.min_by_key(key)
};
let (Some(x), Some(y)) = (pick(db.begin, db.end), pick(db.end, db.begin)) else {
continue;
};
let swaps = u8::from(x != db.stereo_atoms[0]) + u8::from(y != db.stereo_atoms[1]);
let stereo = if swaps % 2 == 1 {
match db.stereo {
BondStereo::Cis => BondStereo::Trans,
_ => BondStereo::Cis,
}
} else {
db.stereo
};
if let Some(mut b) = out.bond_mut(di) {
b.set_stereo(stereo);
b.set_stereo_atoms([x, y]);
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::smiles;
fn ez_from_block(block: &str) -> (usize, String) {
let got = crate::molblock::read_v2000(block).expect("读 molblock");
let mut m = got.mol;
omgkit_chem::pipeline::sanitize(&mut m).expect("净化");
let n = assign_bond_stereo_2d(&mut m, &got.coords, &got.unknown_stereo);
(n, crate::canon::canonical_smiles(&m).smiles)
}
fn ez_from_smiles(smi: &str) -> String {
let mut m = smiles::parse(smi).unwrap_or_else(|e| panic!("{smi}: {}", e.render()));
omgkit_chem::pipeline::sanitize(&mut m).expect("净化");
perceive_bond_stereo(&mut m);
crate::canon::canonical_smiles(&m).smiles
}
const TRANS_DIFLUOROETHENE: &str = "\
F/C=C/F
RDKit 2D
4 3 0 0 0 0 0 0 0 0999 V2000
-1.9796 -0.1365 0.0000 F 0 0 0 0 0 0 0 0 0 0 0 0
-0.5994 0.4508 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
0.5994 -0.4508 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
1.9796 0.1365 0.0000 F 0 0 0 0 0 0 0 0 0 0 0 0
1 2 1 0
2 3 2 0
3 4 1 0
M END
";
#[test]
fn a_trans_double_bond_is_read_as_trans() {
let (n, smi) = ez_from_block(TRANS_DIFLUOROETHENE);
assert_eq!(n, 1, "该标一根");
assert_eq!(smi, ez_from_smiles("F/C=C/F"));
}
#[test]
fn a_crossed_double_bond_is_not_read_as_a_configuration() {
let crossed = TRANS_DIFLUOROETHENE.replace(" 2 3 2 0", " 2 3 2 3");
assert_ne!(crossed, TRANS_DIFLUOROETHENE, "键块那一行没改到");
let (n, smi) = ez_from_block(&crossed);
assert_eq!(n, 0, "不该标");
assert_eq!(smi, ez_from_smiles("FC=CF"));
}
#[test]
fn a_wavy_single_bond_blocks_its_neighbouring_double_bond() {
let wavy = TRANS_DIFLUOROETHENE.replace(" 1 2 1 0", " 1 2 1 4");
assert_ne!(wavy, TRANS_DIFLUOROETHENE, "键块那一行没改到");
let (n, smi) = ez_from_block(&wavy);
assert_eq!(n, 0, "不该标");
assert_eq!(smi, ez_from_smiles("FC=CF"));
}
#[test]
fn three_dimensional_coordinates_are_refused_outright() {
let lifted = TRANS_DIFLUOROETHENE.replace(
" 1.9796 0.1365 0.0000 F",
" 1.9796 0.1365 1.2000 F",
);
assert_ne!(lifted, TRANS_DIFLUOROETHENE, "原子块那一行没改到");
let (n, _) = ez_from_block(&lifted);
assert_eq!(n, 0, "三维不该走这条路");
}
#[test]
fn a_reference_atom_is_never_a_hydrogen_when_a_heavy_one_exists() {
let mut m = smiles::parse("C/C=C/C=C\\C").expect("解析");
omgkit_chem::pipeline::sanitize(&mut m).expect("净化");
perceive_bond_stereo(&mut m);
let ranks = crate::canon::classed_ranks(&m);
omgkit_chem::add_explicit_hs(&mut m, &ranks);
let pr = crate::canon::canonical_ranks(&m);
let norm = normalized_stereo_refs(&m, &pr).expect("有双键要规范化");
let mut checked = 0;
for b in norm.bonds() {
if !matches!(b.stereo, BondStereo::Cis | BondStereo::Trans) {
continue;
}
for r in b.stereo_atoms {
assert_ne!(
norm.atoms()[r as usize].atomic_num,
1,
"参照挑到了氢:{:?}",
b.stereo_atoms
);
checked += 1;
}
}
assert_eq!(checked, 4, "该查两根双键、四个参照");
}
#[test]
fn two_direction_bonds_at_one_end_never_point_the_same_way() {
for smi in [
"N#CCc1ccc([N-]/C=N/C(C#N)=C(\\N)C#N)cc1",
"C/C=C/C=C\\C",
"F/C=C/C=C/F",
"CCOC(=O)/C([O-])=C(C#N)\\C=N\\c1ccccc1",
] {
let mut m = smiles::parse(smi).unwrap_or_else(|e| panic!("{smi}: {}", e.render()));
omgkit_chem::pipeline::sanitize(&mut m).expect("净化");
perceive_bond_stereo(&mut m);
let ranks = crate::canon::classed_ranks(&m);
omgkit_chem::add_explicit_hs(&mut m, &ranks);
let pr = crate::canon::canonical_ranks(&m);
let norm = normalized_stereo_refs(&m, &pr).unwrap_or(m);
let d = directions_for_writing(&norm);
for a in 0..u32::try_from(norm.num_atoms()).expect("原子数") {
let out: Vec<(u32, BondDirection)> = norm
.neighbors(a)
.filter(|&(_, bi)| d.dirs[bi as usize] != BondDirection::None)
.filter(|&(_, bi)| norm.bonds()[bi as usize].order != BondOrder::Double)
.map(|(_, bi)| {
let b = norm.bonds()[bi as usize];
let dir = if b.begin == a {
d.dirs[bi as usize]
} else {
d.dirs[bi as usize].flipped()
};
(bi, dir)
})
.collect();
for i in 0..out.len() {
for j in i + 1..out.len() {
assert_ne!(
out[i].1, out[j].1,
"{smi}:原子 {a} 上的键 {} 与 {} 朝外同号",
out[i].0, out[j].0
);
}
}
}
}
}
#[test]
fn an_end_with_two_identical_substituents_is_not_a_stereo_source() {
let (n, _) = ez_from_block(
"\
CC=C(Cl)Cl
RDKit 2D
5 4 0 0 0 0 0 0 0 0999 V2000
-2.0785 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
-0.7794 0.7500 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
0.5196 -0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
1.8187 0.7500 0.0000 Cl 0 0 0 0 0 0 0 0 0 0 0 0
0.5196 -1.5000 0.0000 Cl 0 0 0 0 0 0 0 0 0 0 0 0
1 2 1 0
2 3 2 0
3 4 1 0
3 5 1 0
M END
",
);
assert_eq!(n, 0, "1,1-二氯丙烯没有顺反");
}
#[test]
fn cumulated_double_bonds_are_not_cis_trans() {
let (n, _) = ez_from_block(
"\
FC=C=CF
RDKit 2D
5 4 0 0 0 0 0 0 0 0999 V2000
-2.2500 0.7794 0.0000 F 0 0 0 0 0 0 0 0 0 0 0 0
-1.5000 -0.5196 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
-0.0000 -0.5196 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
1.5000 -0.5196 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
2.2500 0.7794 0.0000 F 0 0 0 0 0 0 0 0 0 0 0 0
1 2 1 0
2 3 2 0
3 4 2 0
4 5 1 0
M END
",
);
assert_eq!(n, 0, "丙二烯型的两根双键都不是顺反");
}
fn genuine(smi: &str) -> Vec<u32> {
let m = smiles::parse(smi).unwrap_or_else(|e| panic!("{smi}: {}", e.render()));
genuine_tetrahedral(&m)
.into_iter()
.enumerate()
.filter(|&(_, g)| g)
.map(|(i, _)| i as u32)
.collect()
}
#[test]
fn distinguishable_substituents_are_genuine() {
assert_eq!(genuine("N[C@@H](C)C(=O)O"), vec![1], "丙氨酸");
assert_eq!(genuine("[C@H](N)(O)F"), vec![0], "三取代 + 一个氢");
assert_eq!(genuine("C[C@](N)(O)F"), vec![1]);
}
#[test]
fn symmetric_substituents_without_help_are_not_genuine() {
assert!(genuine("C[C@](C)(N)O").is_empty(), "两个甲基");
assert!(genuine("[C@@H]1CCCC1").is_empty(), "对称的环戊烷");
assert!(genuine("[C@@H]1CCCCC1").is_empty(), "对称的环己烷");
}
#[test]
fn dependent_pair_is_genuine() {
assert_eq!(
genuine("O[C@H]1CC[C@@H](N)CC1"),
vec![1, 4],
"1,4-二取代环己烷:两个中心互相成全"
);
assert_eq!(
genuine("C(#C)[C@@]1(CC[C@H](C2=CC=CC=C2)CC1)O").len(),
2,
"同一形状的另一个例子"
);
}
#[test]
fn untagged_atoms_are_never_genuine() {
assert!(genuine("CCO").is_empty());
assert!(genuine("C1CCCCC1").is_empty());
assert!(genuine("c1ccccc1").is_empty());
}
#[test]
fn verdict_is_renumbering_invariant() {
for (a, b) in [
("O[C@H]1CC[C@@H](N)CC1", "N[C@H]1CC[C@@H](O)CC1"),
("N[C@@H](C)C(=O)O", "OC(=O)[C@@H](N)C"),
("[C@@H]1CCCC1", "C1CC[C@@H]1C"),
] {
let ga = genuine(a).len();
let gb = genuine(b).len();
assert_eq!(ga, gb, "{a} 与 {b} 的真手性中心个数应当一致");
}
}
fn sanitized(smi: &str) -> MolBuilder {
let mut m = smiles::parse(smi).unwrap_or_else(|e| panic!("{smi}: {}", e.render()));
omgkit_chem::sanitize(&mut m).expect("净化失败");
m
}
#[test]
fn missing_stereo_perception_is_detected_without_false_alarms() {
for smi in ["C/C=C/C", "C/C=C\\C", "F/C(Cl)=C(/Br)C"] {
let m = sanitized(smi);
assert!(
directions_not_perceived(&m),
"{smi}:两端方向成对却没顺反,该报出来"
);
let mut perceived = sanitized(smi);
perceive_bond_stereo(&mut perceived);
assert!(
!directions_not_perceived(&perceived),
"{smi}:感知跑过了还在报"
);
}
for smi in [
"CC=CC", "C/C=CC", "CCO", "C/C=C/C=C/C", ] {
let mut m = sanitized(smi);
perceive_bond_stereo(&mut m);
assert!(!directions_not_perceived(&m), "{smi}:感知跑过了不该报");
}
let lone = sanitized("C/C=CC");
assert!(
!directions_not_perceived(&lone),
"只有一侧写了方向,几何本就定不下来 —— 没感知过也不该报"
);
}
#[test]
fn 谓词与感知问的是同一个问题() {
for smi in [
"F/C=C/F", "F/C=C\\F", "F/C=C(\\F)F", "Cl/C=C(\\Cl)Cl", "CC(/C=C/C)=C(\\C)C", "F/C=CF", "C/1CCCCC1", "CCO", ] {
let mut m = crate::smiles::parse(smi).expect("解析");
omgkit_chem::pipeline::sanitize(&mut m).expect("净化");
let will_annotate = {
let informative = informative_directions(&m);
(0..m.num_bonds())
.filter(|&i| m.bonds()[i].stereo == BondStereo::None)
.filter(|&i| would_annotate(&m, i, &informative).is_some())
.count()
};
assert_eq!(
directions_not_perceived(&m),
will_annotate > 0,
"{smi}:谓词与感知不一致(感知会标 {will_annotate} 根)"
);
let n = perceive_bond_stereo(&mut m);
assert_eq!(n, will_annotate, "{smi}:实际标的根数与预判不符");
assert!(
!directions_not_perceived(&m),
"{smi}:感知跑过了谓词还在报 —— 这种红没有任何修法"
);
}
}
fn perceive(smi: &str) -> Vec<(u32, u32, BondStereo, [u32; 2])> {
let mut m = smiles::parse(smi).unwrap_or_else(|e| panic!("{smi}: {}", e.render()));
perceive_bond_stereo(&mut m);
m.bonds()
.iter()
.filter(|b| b.stereo != BondStereo::None)
.map(|b| (b.begin, b.end, b.stereo, b.stereo_atoms))
.collect()
}
#[test]
fn cis_and_trans_are_distinguished() {
assert_eq!(
perceive("F/C=C/F"),
vec![(1, 2, BondStereo::Trans, [0, 3])],
"反式"
);
assert_eq!(
perceive("F/C=C\\F"),
vec![(1, 2, BondStereo::Cis, [0, 3])],
"顺式"
);
}
#[test]
fn verdict_does_not_depend_on_how_it_was_written() {
let a = perceive("F/C=C/F");
let b = perceive("C(\\F)=C/F");
assert_eq!(a.len(), 1);
assert_eq!(b.len(), 1);
assert_eq!(a[0].2, b[0].2, "F/C=C/F 与 C(\\F)=C/F 是同一个分子");
}
#[test]
fn reference_atoms_are_the_ones_carrying_the_directions() {
let got = perceive("C/C(F)=C(F)/C");
assert_eq!(got.len(), 1);
let (_, _, stereo, refs) = got[0];
assert_eq!(stereo, BondStereo::Trans);
assert_eq!(refs, [0, 5], "两个甲基才是参照,不是那两个氟");
}
#[test]
fn each_double_bond_gets_its_own_verdict() {
let got = perceive("F/C=C/C=C/F");
assert_eq!(got.len(), 2);
assert!(got.iter().all(|g| g.2 == BondStereo::Trans));
}
#[test]
fn non_informative_directions_are_not_perceived() {
assert!(perceive("C/1CCCCC1").is_empty(), "根本没有双键");
assert!(perceive("F/C=CF").is_empty(), "只有一侧有方向");
assert!(perceive("F/C=C(F)F").is_empty(), "一端两个取代基相同");
assert!(perceive("FC=CF").is_empty(), "没有方向键");
}
#[test]
fn 小环里的双键不给顺反() {
let ring = |n: usize| format!("C/1=C\\{}1", "C".repeat(n - 2));
let double_bond = |m: &MolBuilder| {
m.bonds()
.iter()
.position(|b| b.order == BondOrder::Double)
.expect("有双键") as u32
};
for n in 4..=10 {
let smi = ring(n);
let m = smiles::parse(&smi).unwrap_or_else(|e| panic!("{smi}: {}", e.render()));
assert_eq!(m.num_atoms(), n, "{smi}:环大小没写对,这条用例就测错了东西");
let stereogenic = n >= 8;
assert_eq!(
perceive(&smi).len(),
usize::from(stereogenic),
"{n} 元环 {smi}:感知"
);
assert_eq!(
informative_directions(&m).iter().any(|&x| x),
stereogenic,
"{n} 元环 {smi}:写出"
);
assert_eq!(
raw_cis_trans(&m, double_bond(&m)).is_some(),
stereogenic,
"{n} 元环 {smi}:匹配"
);
}
let with_raw = |m: &MolBuilder| {
(0..m.num_bonds() as u32)
.filter(|&b| raw_cis_trans(m, b).is_some())
.count()
};
for smi in [r"F/C=C1\CCCC(F)C1", r"O=C1CCC/C1=C/F"] {
let m = smiles::parse(smi).unwrap();
assert_eq!(perceive(smi).len(), 1, "{smi}:环外双键,顺反是真的");
assert_eq!(with_raw(&m), 1, "{smi}:匹配那一路也要给");
}
let smi = r"CN1CCC\\2=C1/C(=N\\O)/S/C2=N\\c3ccc(cc3)F";
let got = perceive(smi);
assert_eq!(got.len(), 2, "{smi}:两根环外 C=N 有顺反,环内那根 C=C 没有");
let m = smiles::parse(smi).unwrap();
let ring_db = m
.bonds()
.iter()
.position(|b| {
b.order == BondOrder::Double
&& m.atoms()[b.begin as usize].atomic_num == 6
&& m.atoms()[b.end as usize].atomic_num == 6
})
.expect("有那根 C=C") as u32;
assert!(raw_cis_trans(&m, ring_db).is_none(), "五元环里的 C=C");
}
#[test]
fn validity_follows_the_graph() {
let mut m = smiles::parse("F/C=C/F").unwrap();
perceive_bond_stereo(&mut m);
let db = m
.bonds()
.iter()
.position(|b| b.stereo != BondStereo::None)
.expect("有标注") as u32;
assert!(stereo_atoms_are_valid(&m, db));
let other = (0..m.num_bonds() as u32)
.find(|&i| i != db)
.expect("有别的键");
assert!(!stereo_atoms_are_valid(&m, other));
}
#[test]
fn perceived_stereo_regenerates_directions() {
for smi in ["F/C=C/F", "F/C=C\\F", "C/C=C/C", "C/C(F)=C(F)/C"] {
let mut m = smiles::parse(smi).unwrap();
perceive_bond_stereo(&mut m);
let w = smiles::write(&m).smiles;
let mut back =
smiles::parse(&w).unwrap_or_else(|e| panic!("{smi} → {w}: {}", e.render()));
perceive_bond_stereo(&mut back);
let a: Vec<_> = m
.bonds()
.iter()
.map(|b| b.stereo)
.filter(|s| *s != BondStereo::None)
.collect();
let b: Vec<_> = back
.bonds()
.iter()
.map(|b| b.stereo)
.filter(|s| *s != BondStereo::None)
.collect();
assert_eq!(a, b, "{smi} → {w}:顺反没有守恒");
}
}
#[test]
fn conjugated_chain_directions_stay_consistent() {
for smi in [
"F/C=C/C=C/F",
"F/C=C\\C=C/F",
"F/C=C/C=C\\F",
"C/C=C/C=C/C=C/C",
] {
let mut m = smiles::parse(smi).unwrap();
let n = perceive_bond_stereo(&mut m);
assert!(n >= 2, "{smi}:应当标注到多根双键,实际 {n}");
let w = smiles::write(&m).smiles;
let mut back = smiles::parse(&w).unwrap();
perceive_bond_stereo(&mut back);
let a: Vec<_> = m
.bonds()
.iter()
.filter(|b| b.stereo != BondStereo::None)
.map(|b| (b.stereo, b.stereo_atoms))
.collect();
let b: Vec<_> = back
.bonds()
.iter()
.filter(|b| b.stereo != BondStereo::None)
.map(|b| (b.stereo, b.stereo_atoms))
.collect();
assert_eq!(a, b, "{smi} → {w}:共轭链上的顺反没有全部守恒");
}
}
#[test]
fn stereo_survives_deletion_of_the_direction_bearing_bond() {
let mut m = smiles::parse("C/C=C/F").unwrap();
perceive_bond_stereo(&mut m);
for i in 0..m.num_bonds() as u32 {
if let Some(mut b) = m.bond_mut(i) {
b.set_direction(BondDirection::None);
}
}
let w = smiles::write(&m).smiles;
assert!(
w.contains('/') || w.contains('\\'),
"写成了 {w} —— direction 抹掉之后立体就丢了,说明写出还在读 direction"
);
}
#[test]
fn stale_directions_next_to_a_perceived_double_bond_are_cleared() {
let mut m = smiles::parse("C/C=C/F").unwrap();
perceive_bond_stereo(&mut m);
let db = (0..m.num_bonds() as u32)
.find(|&i| m.bonds()[i as usize].stereo != BondStereo::None)
.expect("有标注");
let ends = [m.bonds()[db as usize].begin, m.bonds()[db as usize].end];
let extra = ends
.iter()
.flat_map(|&e| m.neighbors(e).map(|(_, bi)| bi).collect::<Vec<_>>())
.find(|&bi| bi != db && m.bonds()[bi as usize].direction == BondDirection::None);
if let Some(bi) = extra {
if let Some(mut b) = m.bond_mut(bi) {
b.set_direction(BondDirection::DownRight);
}
}
let dirs = directions_for_writing(&m).dirs;
let n = dirs.iter().filter(|d| **d != BondDirection::None).count();
assert_eq!(n, 2, "一根双键只该有两条方向键,实际 {n} 条");
}
}