use std::collections::{BTreeMap, BTreeSet};
use omgkit_core::{BondData, BondDirection, BondFlags, BondOrder, BondStereo, MolBuilder};
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
}
#[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 {
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]))
}
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 db in mol.bonds() {
if db.order != BondOrder::Double || db.flags.contains(BondFlags::AROMATIC) {
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 directional_bonds_at(mol: &MolBuilder, end: u32, other: u32) -> Vec<u32> {
mol.neighbors(end)
.filter(|&(o, _)| o != other)
.filter(|&(_, bi)| {
let b = mol.bonds()[bi as usize];
b.direction != BondDirection::None && b.order != BondOrder::Double
})
.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, db) in mol.bonds().iter().enumerate() {
if db.order != BondOrder::Double || db.flags.contains(BondFlags::AROMATIC) {
continue;
}
if stereo_atoms_are_valid(mol, u32::try_from(di).unwrap_or(u32::MAX)) {
continue;
}
let Some((ref_b, dir_b)) = outward_direction(mol, db.begin, db.end, &informative) else {
continue;
};
let Some((ref_e, dir_e)) = outward_direction(mol, db.end, db.begin, &informative) else {
continue;
};
let stereo = if dir_b == dir_e {
BondStereo::Cis
} else {
BondStereo::Trans
};
found.push((di as u32, stereo, [ref_b, ref_e]));
}
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 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 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| {
mol.neighbors(end)
.map(|(other, _)| other)
.filter(|&other| other != partner)
.min_by_key(|&other| priority[other as usize])
};
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 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 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 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} 条");
}
}