use crate::{AtomId, BondId, BondOrder, BondStereo, ChiralTag, Molecule};
use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AtropError {
#[error("ring info not available for atropisomer detection")]
NoRingInfo,
#[error("adjacency info not available for atropisomer detection")]
NoAdjacencyInfo,
#[error("invalid bond index {bond} in molecule with {bond_count} bonds")]
InvalidBond { bond: usize, bond_count: usize },
#[error("invalid atom index {atom} in molecule with {atom_count} atoms")]
InvalidAtom { atom: usize, atom_count: usize },
#[error("unsupported branch: {message}")]
UnsupportedBranch { message: &'static str },
}
#[derive(Debug, Clone)]
pub struct AtropisomerParams {
pub max_atrop_bond_ring_size: usize,
pub min_ring_size: usize,
pub only_biaryl: bool,
}
impl Default for AtropisomerParams {
fn default() -> Self {
Self {
max_atrop_bond_ring_size: 8,
min_ring_size: 8,
only_biaryl: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AtropisomerResult {
pub bond: BondId,
pub atoms: [AtomId; 2],
pub neighbor_bonds: [Vec<BondId>; 2],
pub stereo: Option<BondStereo>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AtropisomerAtomsAndBonds {
pub(crate) atoms: [AtomId; 2],
pub(crate) neighbor_bonds: [Vec<BondId>; 2],
}
impl AtropisomerAtomsAndBonds {
pub(crate) fn first_neighbor_atoms(&self, mol: &Molecule) -> Option<[AtomId; 2]> {
let first = mol.bonds().get(self.neighbor_bonds[0].first()?.index())?;
let second = mol.bonds().get(self.neighbor_bonds[1].first()?.index())?;
Some([
if first.begin() == self.atoms[0] {
first.end()
} else if first.end() == self.atoms[0] {
first.begin()
} else {
return None;
},
if second.begin() == self.atoms[1] {
second.end()
} else if second.end() == self.atoms[1] {
second.begin()
} else {
return None;
},
])
}
}
pub(crate) fn atropisomer_atoms_and_bonds(
mol: &Molecule,
bond_id: BondId,
) -> Option<AtropisomerAtomsAndBonds> {
let topology = mol.topology_block();
let bond = topology.bonds.get(bond_id.index())?;
let atoms = [bond.begin(), bond.end()];
let mut neighbor_bonds = [Vec::new(), Vec::new()];
for (side, atom) in atoms.into_iter().enumerate() {
let neighbors = topology.adjacency.neighbors_of(atom.index());
for neighbor in neighbors {
if neighbor.bond == bond_id {
continue;
}
neighbor_bonds[side].push(neighbor.bond);
}
if neighbor_bonds[side].is_empty() {
return None;
}
if neighbor_bonds[side].len() == 2 {
let first = topology.bonds.get(neighbor_bonds[side][0].index())?;
let second = topology.bonds.get(neighbor_bonds[side][1].index())?;
let first_other = if first.begin() == atom {
first.end().index()
} else if first.end() == atom {
first.begin().index()
} else {
return None;
};
let second_other = if second.begin() == atom {
second.end().index()
} else if second.end() == atom {
second.begin().index()
} else {
return None;
};
if second_other < first_other {
neighbor_bonds[side].swap(0, 1);
}
}
}
Some(AtropisomerAtomsAndBonds {
atoms,
neighbor_bonds,
})
}
pub fn get_atropisomer_atoms_and_bonds(mol: &Molecule, bond_id: BondId) -> Option<[AtomId; 2]> {
atropisomer_atoms_and_bonds(mol, bond_id).map(|parts| parts.atoms)
}
fn can_have_direction(order: BondOrder) -> bool {
matches!(order, BondOrder::Single | BondOrder::Aromatic)
}
pub fn detect_atropisomers(
mol: &Molecule,
params: &AtropisomerParams,
) -> Result<Vec<AtropisomerResult>, AtropError> {
let rings = mol
.derived_cache()
.rings
.as_ref()
.ok_or(AtropError::NoRingInfo)?;
let num_atoms = mol.num_atoms();
let _num_bonds = mol.bonds().len();
let degree: Vec<usize> = {
let mut deg = vec![0usize; num_atoms];
for bond in mol.bonds() {
deg[bond.begin().index()] += 1;
deg[bond.end().index()] += 1;
}
deg
};
let hybridization: Vec<crate::Hybridization> =
mol.atoms().iter().map(|a| a.hybridization()).collect();
let mut results = Vec::new();
let mut seen_bonds = HashSet::new();
for bond in mol.bonds() {
let bond_id = bond.id();
if !seen_bonds.insert(bond_id) {
continue;
}
if bond.order() != BondOrder::Single {
continue;
}
if bond.stereo() == BondStereo::Any {
continue;
}
if matches!(bond.stereo(), BondStereo::AtropCw | BondStereo::AtropCcw) {
let Some(parts) = atropisomer_atoms_and_bonds(mol, bond_id) else {
continue;
};
results.push(AtropisomerResult {
bond: bond_id,
atoms: parts.atoms,
neighbor_bonds: parts.neighbor_bonds,
stereo: Some(bond.stereo()),
});
continue;
}
let begin_idx = bond.begin().index();
let end_idx = bond.end().index();
let deg_begin = degree.get(begin_idx).copied().unwrap_or(0);
let deg_end = degree.get(end_idx).copied().unwrap_or(0);
if deg_begin < 2 || deg_begin > 3 || deg_end < 2 || deg_end > 3 {
continue;
}
let hyb_begin = hybridization
.get(begin_idx)
.copied()
.unwrap_or(crate::Hybridization::Unspecified);
let hyb_end = hybridization
.get(end_idx)
.copied()
.unwrap_or(crate::Hybridization::Unspecified);
if hyb_begin != crate::Hybridization::Sp2 || hyb_end != crate::Hybridization::Sp2 {
continue;
}
let ring_count = rings.num_bond_rings(bond_id);
if ring_count > 0 {
let min_ring_sz = rings.min_bond_ring_size(bond_id);
if min_ring_sz < params.min_ring_size {
continue;
}
if params.max_atrop_bond_ring_size > 0 && min_ring_sz > params.max_atrop_bond_ring_size
{
continue;
}
}
if params.only_biaryl {
let atom_begin = &mol.atoms()[begin_idx];
let atom_end = &mol.atoms()[end_idx];
let in_ring_begin = rings.num_atom_rings(atom_begin.id()) > 0;
let in_ring_end = rings.num_atom_rings(atom_end.id()) > 0;
if !in_ring_begin || !in_ring_end {
continue;
}
}
let Some(parts) = atropisomer_atoms_and_bonds(mol, bond_id) else {
continue;
};
results.push(AtropisomerResult {
bond: bond_id,
atoms: parts.atoms,
neighbor_bonds: parts.neighbor_bonds,
stereo: None,
});
}
Ok(results)
}
pub fn does_mol_have_atropisomers(mol: &Molecule) -> bool {
mol.bonds()
.iter()
.any(|b| matches!(b.stereo(), BondStereo::AtropCw | BondStereo::AtropCcw))
}
pub fn assign_atropisomer_stereo(mol: &Molecule) -> Result<Vec<(BondId, ChiralTag)>, AtropError> {
let rings = mol
.derived_cache()
.rings
.as_ref()
.ok_or(AtropError::NoRingInfo)?;
let num_atoms = mol.num_atoms();
let degree: Vec<usize> = {
let mut deg = vec![0usize; num_atoms];
for bond in mol.bonds() {
deg[bond.begin().index()] += 1;
deg[bond.end().index()] += 1;
}
deg
};
let hybridization: Vec<crate::Hybridization> =
mol.atoms().iter().map(|a| a.hybridization()).collect();
let mut assignments = Vec::new();
let mut candidate_bonds: Vec<BondId> = Vec::new();
for bond in mol.bonds() {
if !can_have_direction(bond.order()) {
continue;
}
if !matches!(
bond.direction(),
crate::BondDirection::BeginWedge | crate::BondDirection::BeginDash
) {
continue;
}
let begin = bond.begin();
for nb in mol.bonds() {
if nb.id() == bond.id() {
continue;
}
if nb.begin() == begin || nb.end() == begin {
let nb_id = nb.id();
if !candidate_bonds.contains(&nb_id) {
candidate_bonds.push(nb_id);
}
}
}
}
if candidate_bonds.is_empty() {
return Ok(assignments);
}
for &candidate_id in &candidate_bonds {
let Some(candidate) = mol.bonds().get(candidate_id.index()) else {
continue;
};
if candidate.order() != BondOrder::Single {
continue;
}
if candidate.stereo() == BondStereo::Any {
continue;
}
let begin_idx = candidate.begin().index();
let end_idx = candidate.end().index();
let deg_begin = degree.get(begin_idx).copied().unwrap_or(0);
let deg_end = degree.get(end_idx).copied().unwrap_or(0);
if deg_begin < 2 || deg_begin > 3 || deg_end < 2 || deg_end > 3 {
continue;
}
let hyb_begin = hybridization
.get(begin_idx)
.copied()
.unwrap_or(crate::Hybridization::Unspecified);
let hyb_end = hybridization
.get(end_idx)
.copied()
.unwrap_or(crate::Hybridization::Unspecified);
if hyb_begin != crate::Hybridization::Sp2 || hyb_end != crate::Hybridization::Sp2 {
continue;
}
let ring_count = rings.num_bond_rings(candidate_id);
if ring_count > 0 {
let min_sz = rings.min_bond_ring_size(candidate_id);
if min_sz < 8 {
continue;
}
}
let Some(parts) = atropisomer_atoms_and_bonds(mol, candidate_id) else {
continue;
};
let [nbr0, nbr1] = &parts.neighbor_bonds;
let dir0 = get_end_wedge_direction(mol, nbr0, candidate.begin());
let dir1 = get_end_wedge_direction(mol, nbr1, candidate.end());
let (has_dir0, wedge_dir0) = dir0;
let (has_dir1, wedge_dir1) = dir1;
if !has_dir0 || !has_dir1 {
continue;
}
if wedge_dir0 == wedge_dir1 {
continue;
}
let stereo = match (wedge_dir0, wedge_dir1) {
(crate::BondDirection::BeginWedge, crate::BondDirection::BeginDash) => {
BondStereo::AtropCcw
}
(crate::BondDirection::BeginDash, crate::BondDirection::BeginWedge) => {
BondStereo::AtropCw
}
_ => continue,
};
let chiral_tag = match stereo {
BondStereo::AtropCw => ChiralTag::TetrahedralCw,
BondStereo::AtropCcw => ChiralTag::TetrahedralCcw,
_ => ChiralTag::Unspecified,
};
assignments.push((candidate_id, chiral_tag));
}
Ok(assignments)
}
fn get_end_wedge_direction(
mol: &Molecule,
nbr_bonds: &[BondId],
_focus_atom: AtomId,
) -> (bool, crate::BondDirection) {
if nbr_bonds.is_empty() {
return (false, crate::BondDirection::None);
}
let bond0 = match mol.bonds().get(nbr_bonds[0].index()) {
Some(b) => b,
None => return (false, crate::BondDirection::None),
};
let bond1 = if nbr_bonds.len() > 1 {
mol.bonds().get(nbr_bonds[1].index())
} else {
None
};
let dir0 = bond0.direction();
let effective_dir0 = if is_wedge_or_dash(dir0) {
dir0
} else {
crate::BondDirection::None
};
let dir1 = bond1
.map(|b| b.direction())
.unwrap_or(crate::BondDirection::None);
let effective_dir1 = if is_wedge_or_dash(dir1) {
dir1
} else {
crate::BondDirection::None
};
if effective_dir0 != crate::BondDirection::None
&& effective_dir1 != crate::BondDirection::None
&& effective_dir0 == effective_dir1
{
return (false, crate::BondDirection::None);
}
if effective_dir0 == crate::BondDirection::BeginWedge
|| effective_dir1 == crate::BondDirection::BeginDash
{
return (true, crate::BondDirection::BeginWedge);
}
if effective_dir0 == crate::BondDirection::BeginDash
|| effective_dir1 == crate::BondDirection::BeginWedge
{
return (true, crate::BondDirection::BeginDash);
}
(true, crate::BondDirection::None)
}
fn is_wedge_or_dash(dir: crate::BondDirection) -> bool {
matches!(
dir,
crate::BondDirection::BeginWedge | crate::BondDirection::BeginDash
)
}
pub fn cleanup_atropisomer_stereo_groups(mol: &Molecule) -> Vec<BondId> {
let mut atrop_bonds = Vec::new();
for bond in mol.bonds() {
if matches!(bond.stereo(), BondStereo::AtropCw | BondStereo::AtropCcw) {
atrop_bonds.push(bond.id());
}
}
atrop_bonds
}
pub fn validate_atropisomer_assignment(mol: &Molecule, bond_id: BondId) -> Result<(), AtropError> {
let rings = mol
.derived_cache()
.rings
.as_ref()
.ok_or(AtropError::NoRingInfo)?;
let bond = mol
.bonds()
.get(bond_id.index())
.ok_or(AtropError::InvalidBond {
bond: bond_id.index(),
bond_count: mol.bonds().len(),
})?;
if bond.order() != BondOrder::Single {
return Err(AtropError::UnsupportedBranch {
message: "atropisomer bond must be single",
});
}
let begin_h = mol.atoms()[bond.begin().index()].hybridization();
let end_h = mol.atoms()[bond.end().index()].hybridization();
if begin_h != crate::Hybridization::Sp2 || end_h != crate::Hybridization::Sp2 {
return Err(AtropError::UnsupportedBranch {
message: "atropisomer bond endpoints must be sp2 hybridized",
});
}
let ring_count = rings.num_bond_rings(bond_id);
if ring_count > 0 {
let min_sz = rings.min_bond_ring_size(bond_id);
if min_sz < 8 {
return Err(AtropError::UnsupportedBranch {
message: "atropisomer bond is in a ring smaller than 8",
});
}
}
if atropisomer_atoms_and_bonds(mol, bond_id).is_none() {
return Err(AtropError::UnsupportedBranch {
message: "atropisomer bond must have neighbor bonds on both ends",
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Element, Molecule, atom::AtomSpec, bond::BondSpec, builder::MoleculeBuilder};
#[test]
fn atropisomer_atoms_and_bonds_orders_two_neighbors_by_other_atom_index() {
let mut builder = MoleculeBuilder::new();
let low_left = builder.add_atom(AtomSpec::new(Element::C));
let begin = builder.add_atom(AtomSpec::new(Element::C));
let end = builder.add_atom(AtomSpec::new(Element::C));
let low_right = builder.add_atom(AtomSpec::new(Element::C));
let high_left = builder.add_atom(AtomSpec::new(Element::C));
let high_right = builder.add_atom(AtomSpec::new(Element::C));
let axis = builder
.add_bond(BondSpec::new(begin, end, BondOrder::Single))
.unwrap();
let high_left_bond = builder
.add_bond(BondSpec::new(begin, high_left, BondOrder::Single))
.unwrap();
let low_left_bond = builder
.add_bond(BondSpec::new(begin, low_left, BondOrder::Single))
.unwrap();
let high_right_bond = builder
.add_bond(BondSpec::new(end, high_right, BondOrder::Single))
.unwrap();
let low_right_bond = builder
.add_bond(BondSpec::new(end, low_right, BondOrder::Single))
.unwrap();
let molecule = builder.build().unwrap();
let parts = atropisomer_atoms_and_bonds(&molecule, axis).unwrap();
assert_eq!(parts.atoms, [begin, end]);
assert_eq!(
parts.neighbor_bonds,
[
vec![low_left_bond, high_left_bond],
vec![low_right_bond, high_right_bond],
]
);
assert_eq!(
parts.first_neighbor_atoms(&molecule),
Some([low_left, low_right])
);
assert_eq!(
get_atropisomer_atoms_and_bonds(&molecule, axis),
Some([begin, end])
);
}
#[test]
fn atropisomer_atoms_and_bonds_preserves_adjacency_order_above_two_neighbors() {
let mut builder = MoleculeBuilder::new();
let begin = builder.add_atom(AtomSpec::new(Element::C));
let end = builder.add_atom(AtomSpec::new(Element::C));
let first = builder.add_atom(AtomSpec::new(Element::C));
let second = builder.add_atom(AtomSpec::new(Element::C));
let third = builder.add_atom(AtomSpec::new(Element::C));
let right = builder.add_atom(AtomSpec::new(Element::C));
let axis = builder
.add_bond(BondSpec::new(begin, end, BondOrder::Single))
.unwrap();
let first_bond = builder
.add_bond(BondSpec::new(begin, third, BondOrder::Single))
.unwrap();
let second_bond = builder
.add_bond(BondSpec::new(begin, first, BondOrder::Single))
.unwrap();
let third_bond = builder
.add_bond(BondSpec::new(begin, second, BondOrder::Single))
.unwrap();
let right_bond = builder
.add_bond(BondSpec::new(end, right, BondOrder::Single))
.unwrap();
let molecule = builder.build().unwrap();
let parts = atropisomer_atoms_and_bonds(&molecule, axis).unwrap();
assert_eq!(
parts.neighbor_bonds,
[vec![first_bond, second_bond, third_bond], vec![right_bond]]
);
assert_eq!(parts.first_neighbor_atoms(&molecule), Some([third, right]));
}
#[test]
fn atropisomer_atoms_and_bonds_rejects_an_axis_with_a_dead_end() {
let mut builder = MoleculeBuilder::new();
let begin = builder.add_atom(AtomSpec::new(Element::C));
let end = builder.add_atom(AtomSpec::new(Element::C));
let left = builder.add_atom(AtomSpec::new(Element::C));
let axis = builder
.add_bond(BondSpec::new(begin, end, BondOrder::Single))
.unwrap();
builder
.add_bond(BondSpec::new(begin, left, BondOrder::Single))
.unwrap();
let molecule = builder.build().unwrap();
assert!(atropisomer_atoms_and_bonds(&molecule, axis).is_none());
assert!(get_atropisomer_atoms_and_bonds(&molecule, axis).is_none());
}
fn build_simple_biaryl() -> Molecule {
let mut builder = MoleculeBuilder::new();
let c1 = builder.add_atom(
AtomSpec::new(Element::C)
.with_aromatic(true)
.with_hybridization(crate::Hybridization::Sp2),
);
let c2 = builder.add_atom(
AtomSpec::new(Element::C)
.with_aromatic(true)
.with_hybridization(crate::Hybridization::Sp2),
);
let c3 = builder.add_atom(
AtomSpec::new(Element::C)
.with_aromatic(true)
.with_hybridization(crate::Hybridization::Sp2),
);
let c4 = builder.add_atom(
AtomSpec::new(Element::C)
.with_aromatic(true)
.with_hybridization(crate::Hybridization::Sp2),
);
let c5 = builder.add_atom(
AtomSpec::new(Element::C)
.with_aromatic(true)
.with_hybridization(crate::Hybridization::Sp2),
);
let c6 = builder.add_atom(
AtomSpec::new(Element::C)
.with_aromatic(true)
.with_hybridization(crate::Hybridization::Sp2),
);
let c7 = builder.add_atom(
AtomSpec::new(Element::C)
.with_aromatic(true)
.with_hybridization(crate::Hybridization::Sp2),
);
let c8 = builder.add_atom(
AtomSpec::new(Element::C)
.with_aromatic(true)
.with_hybridization(crate::Hybridization::Sp2),
);
let c9 = builder.add_atom(
AtomSpec::new(Element::C)
.with_aromatic(true)
.with_hybridization(crate::Hybridization::Sp2),
);
let c10 = builder.add_atom(
AtomSpec::new(Element::C)
.with_aromatic(true)
.with_hybridization(crate::Hybridization::Sp2),
);
let c11 = builder.add_atom(
AtomSpec::new(Element::C)
.with_aromatic(true)
.with_hybridization(crate::Hybridization::Sp2),
);
let c12 = builder.add_atom(
AtomSpec::new(Element::C)
.with_aromatic(true)
.with_hybridization(crate::Hybridization::Sp2),
);
builder.add_bond(BondSpec::new(c1, c2, BondOrder::Aromatic));
builder.add_bond(BondSpec::new(c2, c3, BondOrder::Aromatic));
builder.add_bond(BondSpec::new(c3, c4, BondOrder::Aromatic));
builder.add_bond(BondSpec::new(c4, c5, BondOrder::Aromatic));
builder.add_bond(BondSpec::new(c5, c6, BondOrder::Aromatic));
builder.add_bond(BondSpec::new(c6, c1, BondOrder::Aromatic));
builder.add_bond(BondSpec::new(c7, c8, BondOrder::Aromatic));
builder.add_bond(BondSpec::new(c8, c9, BondOrder::Aromatic));
builder.add_bond(BondSpec::new(c9, c10, BondOrder::Aromatic));
builder.add_bond(BondSpec::new(c10, c11, BondOrder::Aromatic));
builder.add_bond(BondSpec::new(c11, c12, BondOrder::Aromatic));
builder.add_bond(BondSpec::new(c12, c7, BondOrder::Aromatic));
builder.add_bond(BondSpec::new(c1, c7, BondOrder::Single));
builder.build().expect("molecule should build")
}
#[test]
fn test_detect_atropisomers_biaryl() {
let mol = build_simple_biaryl();
let result = detect_atropisomers(&mol, &AtropisomerParams::default());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AtropError::NoRingInfo));
}
#[test]
fn test_get_atropisomer_neighbor_bonds_empty() {
let mut builder = MoleculeBuilder::new();
let _c1 = builder.add_atom(AtomSpec::new(Element::C));
let _mol = builder.build().expect("molecule should build");
}
#[test]
fn test_does_mol_have_atropisomers_default() {
let mol = build_simple_biaryl();
assert!(!does_mol_have_atropisomers(&mol));
}
#[test]
fn test_assign_atropisomer_stereo_no_wedges() {
let mol = build_simple_biaryl();
let result = assign_atropisomer_stereo(&mol);
assert!(result.is_err() || result.unwrap().is_empty());
}
#[test]
fn test_validate_bond_invalid() {
let mol = build_simple_biaryl();
let result = validate_atropisomer_assignment(&mol, BondId::new(99));
assert!(
result.is_err(),
"expected error for out-of-range bond, got Ok"
);
}
}