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>,
}
pub fn get_atropisomer_atoms_and_bonds(mol: &Molecule, bond_id: BondId) -> Option<[AtomId; 2]> {
let bond = mol.bonds().get(bond_id.index())?;
Some([bond.begin(), bond.end()])
}
fn get_atropisomer_neighbor_bonds(
mol: &Molecule,
focus_atom: AtomId,
atrop_bond: BondId,
) -> Option<Vec<BondId>> {
let mut nbr_bonds: Vec<BondId> = mol
.bonds()
.iter()
.filter(|b| b.id() != atrop_bond && (b.begin() == focus_atom || b.end() == focus_atom))
.map(|b| b.id())
.collect();
if nbr_bonds.is_empty() {
return None;
}
if nbr_bonds.len() == 2 {
let other0 = bond_other_atom(mol, nbr_bonds[0], focus_atom)?;
let other1 = bond_other_atom(mol, nbr_bonds[1], focus_atom)?;
if other1.index() < other0.index() {
nbr_bonds.swap(0, 1);
}
}
Some(nbr_bonds)
}
fn bond_other_atom(mol: &Molecule, bond_id: BondId, atom: AtomId) -> Option<AtomId> {
let bond = mol.bonds().get(bond_id.index())?;
if bond.begin() == atom {
Some(bond.end())
} else if bond.end() == atom {
Some(bond.begin())
} else {
None
}
}
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 begin = bond.begin();
let end = bond.end();
let nbr_begin = get_atropisomer_neighbor_bonds(mol, begin, bond_id);
let nbr_end = get_atropisomer_neighbor_bonds(mol, end, bond_id);
let (nbr0, nbr1) = match (nbr_begin, nbr_end) {
(Some(n0), Some(n1)) => (n0, n1),
_ => continue,
};
results.push(AtropisomerResult {
bond: bond_id,
atoms: [begin, end],
neighbor_bonds: [nbr0, nbr1],
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 begin = bond.begin();
let end = bond.end();
let nbr_begin = get_atropisomer_neighbor_bonds(mol, begin, bond_id);
let nbr_end = get_atropisomer_neighbor_bonds(mol, end, bond_id);
let (nbr0, nbr1) = match (nbr_begin, nbr_end) {
(Some(n0), Some(n1)) => (n0, n1),
_ => continue,
};
if nbr0.is_empty() || nbr1.is_empty() {
continue;
}
results.push(AtropisomerResult {
bond: bond_id,
atoms: [begin, end],
neighbor_bonds: [nbr0, nbr1],
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 begin = candidate.begin();
let end = candidate.end();
let nbr_begin = get_atropisomer_neighbor_bonds(mol, begin, candidate_id);
let nbr_end = get_atropisomer_neighbor_bonds(mol, end, candidate_id);
let (nbr0, nbr1) = match (nbr_begin, nbr_end) {
(Some(n0), Some(n1)) => (n0, n1),
_ => continue,
};
if nbr0.is_empty() || nbr1.is_empty() {
continue;
}
let dir0 = get_end_wedge_direction(mol, &nbr0, begin);
let dir1 = get_end_wedge_direction(mol, &nbr1, 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",
});
}
}
let nbr_begin = get_atropisomer_neighbor_bonds(mol, bond.begin(), bond_id);
let nbr_end = get_atropisomer_neighbor_bonds(mol, bond.end(), bond_id);
match (nbr_begin, nbr_end) {
(Some(n0), Some(n1)) if !n0.is_empty() && !n1.is_empty() => {}
_ => {
return Err(AtropError::UnsupportedBranch {
message: "atropisomer bond must have neighbor bonds on both ends",
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Molecule;
fn build_simple_biaryl() -> Molecule {
use crate::Element;
use crate::atom::AtomSpec;
use crate::bond::BondSpec;
use crate::builder::MoleculeBuilder;
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() {
use crate::Element;
use crate::atom::AtomSpec;
use crate::builder::MoleculeBuilder;
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"
);
}
}