use chematic_core::{AtomIdx, Molecule};
#[derive(Clone, Debug)]
pub struct TorsionPreference {
pub angle_deg: f64,
pub penalty_per_degree: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AtomType {
CSp3,
CSp2Alkene,
CAromatic,
CCarbonyl,
NSp,
NSp2,
NAromatic,
NSp3,
OSp2,
OSp3,
OAromatic,
S,
SAromatic,
P,
H,
Halogen,
Other,
}
fn count_incident_bonds(mol: &Molecule, idx: AtomIdx, order: chematic_core::BondOrder) -> usize {
mol.bonds()
.filter(|(_, bond)| (bond.atom1 == idx || bond.atom2 == idx) && bond.order == order)
.count()
}
pub fn classify_atom_type(mol: &Molecule, idx: AtomIdx) -> AtomType {
let atom = mol.atom(idx);
let an = atom.element.atomic_number();
match an {
1 => AtomType::H,
6 => {
let double_bonds = count_incident_bonds(mol, idx, chematic_core::BondOrder::Double);
if atom.aromatic {
AtomType::CAromatic
} else if double_bonds > 0 {
let has_o_neighbor = mol.neighbors(idx).any(|(n_idx, _)| {
mol.atom(n_idx).element.atomic_number() == 8
&& mol
.bond_between(idx, n_idx)
.map(|(_, b)| b.order == chematic_core::BondOrder::Double)
.unwrap_or(false)
});
if has_o_neighbor {
AtomType::CCarbonyl
} else {
AtomType::CSp2Alkene
}
} else {
AtomType::CSp3
}
}
7 => {
if atom.aromatic {
AtomType::NAromatic
} else {
let triple_bonds = count_incident_bonds(mol, idx, chematic_core::BondOrder::Triple);
let neighbors = mol.neighbors(idx).count();
if triple_bonds > 0 {
AtomType::NSp
} else if neighbors <= 2 {
AtomType::NSp2
} else {
AtomType::NSp3
}
}
}
8 => {
if atom.aromatic {
AtomType::OAromatic } else {
let has_double_bond = mol.bonds().any(|(_, bond)| {
(bond.atom1 == idx || bond.atom2 == idx)
&& bond.order == chematic_core::BondOrder::Double
});
if has_double_bond {
AtomType::OSp2 } else {
AtomType::OSp3 }
}
}
16 => {
if atom.aromatic {
AtomType::SAromatic } else {
AtomType::S
}
}
15 => AtomType::P,
9 | 17 | 35 | 53 => AtomType::Halogen,
_ => AtomType::Other,
}
}
pub fn get_torsion_preference(
mol: &Molecule,
a_idx: AtomIdx,
b_idx: AtomIdx,
c_idx: AtomIdx,
d_idx: AtomIdx,
) -> Option<TorsionPreference> {
let a_type = classify_atom_type(mol, a_idx);
let b_type = classify_atom_type(mol, b_idx);
let c_type = classify_atom_type(mol, c_idx);
let d_type = classify_atom_type(mol, d_idx);
if b_type == AtomType::CSp3
&& c_type == AtomType::CSp3
&& (a_type == AtomType::CSp3 || a_type == AtomType::H)
&& (d_type == AtomType::CSp3 || d_type == AtomType::H)
{
return Some(TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.15, });
}
if (a_type == AtomType::CSp3 || a_type == AtomType::H)
&& b_type == AtomType::CAromatic
&& c_type == AtomType::CSp3
&& (d_type == AtomType::CSp3 || d_type == AtomType::H)
{
return Some(TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.08, });
}
if b_type == AtomType::NSp2 && c_type == AtomType::CCarbonyl {
return Some(TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.20, });
}
if b_type == AtomType::CCarbonyl && c_type == AtomType::OSp3 {
return Some(TorsionPreference {
angle_deg: 0.0,
penalty_per_degree: 0.10,
});
}
if b_type == AtomType::CAromatic && c_type == AtomType::CAromatic {
return Some(TorsionPreference {
angle_deg: 45.0,
penalty_per_degree: 0.03, });
}
if b_type == AtomType::CSp2Alkene && c_type == AtomType::NSp2 {
return Some(TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.12,
});
}
if b_type == AtomType::NSp2 && c_type == AtomType::CSp2Alkene {
return Some(TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.12,
});
}
if (b_type == AtomType::CSp2Alkene || b_type == AtomType::CAromatic)
&& c_type == AtomType::Halogen
{
return Some(TorsionPreference {
angle_deg: 0.0,
penalty_per_degree: 0.08,
});
}
if b_type == AtomType::CSp2Alkene && c_type == AtomType::CCarbonyl {
return Some(TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.10,
});
}
if b_type == AtomType::CAromatic && c_type == AtomType::CCarbonyl {
return Some(TorsionPreference {
angle_deg: 0.0,
penalty_per_degree: 0.08,
});
}
if b_type == AtomType::CCarbonyl && c_type == AtomType::CAromatic {
return Some(TorsionPreference {
angle_deg: 0.0,
penalty_per_degree: 0.08,
});
}
if b_type == AtomType::S && c_type == AtomType::CCarbonyl {
return Some(TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.10,
});
}
if b_type == AtomType::NSp3 && c_type == AtomType::CCarbonyl {
return Some(TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.10,
});
}
if b_type == AtomType::S && c_type == AtomType::CSp3 {
return Some(TorsionPreference {
angle_deg: 90.0,
penalty_per_degree: 0.06,
});
}
if b_type == AtomType::S && c_type == AtomType::S {
return Some(TorsionPreference {
angle_deg: 90.0,
penalty_per_degree: 0.12,
});
}
if (b_type == AtomType::CSp3 || b_type == AtomType::CSp2Alkene) && c_type == AtomType::OSp3 {
return Some(TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.07,
});
}
if b_type == AtomType::CSp3 && (c_type == AtomType::NSp3 || c_type == AtomType::NSp2) {
return Some(TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.08,
});
}
if b_type == AtomType::NSp || d_type == AtomType::NSp {
return Some(TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.20,
});
}
if b_type == AtomType::P || c_type == AtomType::P {
return Some(TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.06,
});
}
if b_type == AtomType::NSp2
&& c_type == AtomType::NSp2
&& mol
.neighbors(b_idx)
.any(|(n, _)| classify_atom_type(mol, n) == AtomType::CCarbonyl)
{
return Some(TorsionPreference {
angle_deg: 0.0,
penalty_per_degree: 0.18,
});
}
if (b_type == AtomType::NSp3 || b_type == AtomType::NSp2) && c_type == AtomType::S {
return Some(TorsionPreference {
angle_deg: 90.0,
penalty_per_degree: 0.08,
});
}
if b_type == AtomType::S && (c_type == AtomType::NSp3 || c_type == AtomType::NSp2) {
return Some(TorsionPreference {
angle_deg: 90.0,
penalty_per_degree: 0.08,
});
}
if b_type == AtomType::CAromatic && c_type == AtomType::OSp3 {
return Some(TorsionPreference {
angle_deg: 0.0,
penalty_per_degree: 0.09,
});
}
if b_type == AtomType::OSp3 && c_type == AtomType::CAromatic {
return Some(TorsionPreference {
angle_deg: 0.0,
penalty_per_degree: 0.09,
});
}
if (b_type == AtomType::CSp3 || b_type == AtomType::CSp2Alkene)
&& c_type == AtomType::Halogen
&& (d_type == AtomType::H || d_type == AtomType::Halogen)
{
return Some(TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.07,
});
}
if b_type == AtomType::CAromatic && c_type == AtomType::NSp2 {
return Some(TorsionPreference {
angle_deg: 0.0,
penalty_per_degree: 0.12,
});
}
if (b_type == AtomType::CSp2Alkene || b_type == AtomType::CCarbonyl)
&& (c_type == AtomType::NSp2 || c_type == AtomType::OSp3)
{
return Some(TorsionPreference {
angle_deg: 0.0,
penalty_per_degree: 0.11,
});
}
if b_type == AtomType::NSp2
&& c_type == AtomType::CCarbonyl
&& mol
.neighbors(c_idx)
.any(|(n, _)| classify_atom_type(mol, n) == AtomType::CCarbonyl)
{
return Some(TorsionPreference {
angle_deg: 0.0,
penalty_per_degree: 0.15,
});
}
if b_type == AtomType::CAromatic && c_type == AtomType::CSp3 {
return Some(TorsionPreference {
angle_deg: 90.0,
penalty_per_degree: 0.04,
});
}
if b_type == AtomType::CSp2Alkene && c_type == AtomType::CSp3 {
return Some(TorsionPreference {
angle_deg: 0.0,
penalty_per_degree: 0.05,
});
}
if (b_type == AtomType::NAromatic && c_type == AtomType::CAromatic)
|| (b_type == AtomType::CAromatic && c_type == AtomType::NAromatic)
{
return Some(TorsionPreference {
angle_deg: 45.0,
penalty_per_degree: 0.03,
});
}
if (b_type == AtomType::NAromatic && c_type == AtomType::CSp3)
|| (b_type == AtomType::CSp3 && c_type == AtomType::NAromatic)
{
return Some(TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.09,
});
}
if (b_type == AtomType::NAromatic && c_type == AtomType::CCarbonyl)
|| (b_type == AtomType::CCarbonyl && c_type == AtomType::NAromatic)
{
return Some(TorsionPreference {
angle_deg: 0.0,
penalty_per_degree: 0.10,
});
}
if (b_type == AtomType::NAromatic && c_type == AtomType::CSp2Alkene)
|| (b_type == AtomType::CSp2Alkene && c_type == AtomType::NAromatic)
{
return Some(TorsionPreference {
angle_deg: 0.0,
penalty_per_degree: 0.08,
});
}
if (b_type == AtomType::S && c_type == AtomType::CAromatic)
|| (b_type == AtomType::CAromatic && c_type == AtomType::S)
{
return Some(TorsionPreference {
angle_deg: 90.0,
penalty_per_degree: 0.06,
});
}
if b_type == AtomType::OSp3 && c_type == AtomType::CSp3 {
return Some(TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.07,
});
}
if (b_type == AtomType::CAromatic && c_type == AtomType::NSp3)
|| (b_type == AtomType::NSp3 && c_type == AtomType::CAromatic)
{
return Some(TorsionPreference {
angle_deg: 0.0,
penalty_per_degree: 0.07,
});
}
if (b_type == AtomType::OAromatic && c_type == AtomType::CAromatic)
|| (b_type == AtomType::CAromatic && c_type == AtomType::OAromatic)
{
return Some(TorsionPreference {
angle_deg: 0.0,
penalty_per_degree: 0.05,
});
}
if (b_type == AtomType::SAromatic && c_type == AtomType::CAromatic)
|| (b_type == AtomType::CAromatic && c_type == AtomType::SAromatic)
{
return Some(TorsionPreference {
angle_deg: 45.0,
penalty_per_degree: 0.04,
});
}
if (b_type == AtomType::OAromatic && c_type == AtomType::CSp3)
|| (b_type == AtomType::CSp3 && c_type == AtomType::OAromatic)
{
return Some(TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.06,
});
}
if (b_type == AtomType::SAromatic && c_type == AtomType::CSp3)
|| (b_type == AtomType::CSp3 && c_type == AtomType::SAromatic)
{
return Some(TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.06,
});
}
if (b_type == AtomType::OAromatic && c_type == AtomType::CCarbonyl)
|| (b_type == AtomType::CCarbonyl && c_type == AtomType::OAromatic)
|| (b_type == AtomType::SAromatic && c_type == AtomType::CCarbonyl)
|| (b_type == AtomType::CCarbonyl && c_type == AtomType::SAromatic)
{
return Some(TorsionPreference {
angle_deg: 0.0,
penalty_per_degree: 0.10,
});
}
let is_sat_n = |t: AtomType| t == AtomType::NSp2 || t == AtomType::NSp3;
if b_type == AtomType::CSp3
&& c_type == AtomType::CSp3
&& ((is_sat_n(a_type) && d_type == AtomType::OSp3)
|| (a_type == AtomType::OSp3 && is_sat_n(d_type)))
{
return Some(TorsionPreference {
angle_deg: 60.0,
penalty_per_degree: 0.10,
});
}
if b_type == AtomType::CSp3 && c_type == AtomType::CSp3 && is_sat_n(a_type) && is_sat_n(d_type)
{
return Some(TorsionPreference {
angle_deg: 60.0,
penalty_per_degree: 0.10,
});
}
None }
pub fn default_torsion_preference() -> TorsionPreference {
TorsionPreference {
angle_deg: 180.0, penalty_per_degree: 0.10,
}
}
fn normalize_angle(angle_deg: f64) -> f64 {
let mut norm = angle_deg % 360.0;
if norm > 180.0 {
norm -= 360.0;
} else if norm < -180.0 {
norm += 360.0;
}
norm
}
pub fn score_torsion(angle_deg: f64, preference: &TorsionPreference) -> f64 {
let norm = normalize_angle(angle_deg);
let pref = normalize_angle(preference.angle_deg);
let diff = (norm - pref).abs();
let min_diff = if diff > 180.0 { 360.0 - diff } else { diff };
min_diff * preference.penalty_per_degree
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_smiles::parse;
#[test]
fn test_atom_type_methane() {
let mol = parse("C").unwrap();
assert_eq!(classify_atom_type(&mol, AtomIdx(0)), AtomType::CSp3);
}
#[test]
fn test_atom_type_ethene() {
let mol = parse("C=C").unwrap();
assert_eq!(classify_atom_type(&mol, AtomIdx(0)), AtomType::CSp2Alkene);
assert_eq!(classify_atom_type(&mol, AtomIdx(1)), AtomType::CSp2Alkene);
}
#[test]
fn test_atom_type_benzene() {
let mol = parse("c1ccccc1").unwrap();
assert_eq!(classify_atom_type(&mol, AtomIdx(0)), AtomType::CAromatic);
}
#[test]
fn test_atom_type_acetaldehyde() {
let mol = parse("CC=O").unwrap();
let c_sp3 = if mol.atom(AtomIdx(0)).aromatic {
classify_atom_type(&mol, AtomIdx(1))
} else {
classify_atom_type(&mol, AtomIdx(0))
};
assert_eq!(c_sp3, AtomType::CSp3);
}
#[test]
fn test_torsion_score_perfect_match() {
let pref = TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.1,
};
let score = score_torsion(180.0, &pref);
assert!(score.abs() < 1e-6, "perfect match should have zero penalty");
}
#[test]
fn test_torsion_score_deviation() {
let pref = TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.1,
};
let score = score_torsion(160.0, &pref);
assert!(
(score - 2.0).abs() < 1e-6,
"20° deviation should yield 2.0 penalty"
);
}
#[test]
fn test_torsion_score_periodic() {
let pref = TorsionPreference {
angle_deg: 180.0,
penalty_per_degree: 0.1,
};
let score1 = score_torsion(180.0, &pref);
let score2 = score_torsion(-180.0, &pref);
assert!(
(score1 - score2).abs() < 1e-6,
"periodic angles should score the same"
);
}
#[test]
fn test_default_torsion_preference() {
let pref = default_torsion_preference();
assert_eq!(pref.angle_deg, 180.0);
assert!(pref.penalty_per_degree > 0.0);
}
#[test]
fn test_alkane_torsion_preference() {
let mol = parse("CCCC").unwrap(); if mol.atom_count() >= 4 {
let pref = get_torsion_preference(&mol, AtomIdx(0), AtomIdx(1), AtomIdx(2), AtomIdx(3));
assert!(pref.is_some(), "butane C-C-C-C should have preference");
if let Some(p) = pref {
assert_eq!(p.angle_deg, 180.0, "alkane torsions prefer 180°");
}
}
}
#[test]
fn test_biphenyl_torsion_preference() {
let mol = parse("c1ccccc1-c1ccccc1").unwrap(); let pref = get_torsion_preference(&mol, AtomIdx(1), AtomIdx(0), AtomIdx(6), AtomIdx(7));
assert!(
pref.is_some(),
"biphenyl Ar-Ar should have a torsion preference"
);
if let Some(p) = pref {
assert_eq!(p.angle_deg, 45.0, "biphenyl prefers ~45° twist");
}
}
#[test]
fn test_thioester_torsion_preference() {
let mol = parse("SC=O").unwrap();
let _pref = get_torsion_preference(&mol, AtomIdx(0), AtomIdx(0), AtomIdx(1), AtomIdx(2));
let b_type = classify_atom_type(&mol, AtomIdx(0));
let c_type = classify_atom_type(&mol, AtomIdx(1));
assert_eq!(b_type, AtomType::S);
assert_eq!(c_type, AtomType::CCarbonyl);
}
#[test]
fn test_disulfide_torsion_preference() {
let mol = parse("CSSC").unwrap(); let b_type = classify_atom_type(&mol, AtomIdx(1));
let c_type = classify_atom_type(&mol, AtomIdx(2));
assert_eq!(b_type, AtomType::S);
assert_eq!(c_type, AtomType::S);
let pref = get_torsion_preference(&mol, AtomIdx(0), AtomIdx(1), AtomIdx(2), AtomIdx(3));
assert!(pref.is_some(), "disulfide should have ~90° preference");
if let Some(p) = pref {
assert_eq!(p.angle_deg, 90.0, "disulfide prefers 90°");
}
}
#[test]
fn test_nitrile_torsion_preference() {
let mol = parse("CCC#N").unwrap(); let n_type = classify_atom_type(&mol, AtomIdx(3));
assert_eq!(n_type, AtomType::NSp, "nitrile N should be NSp");
let pref = get_torsion_preference(&mol, AtomIdx(0), AtomIdx(1), AtomIdx(2), AtomIdx(3));
assert!(pref.is_some(), "nitrile torsion should have a preference");
if let Some(p) = pref {
assert_eq!(p.angle_deg, 180.0, "linear nitrile end prefers 180°");
}
}
#[test]
fn test_amine_torsion_preference() {
let mol = parse("CCNC").unwrap(); let pref = get_torsion_preference(&mol, AtomIdx(0), AtomIdx(1), AtomIdx(2), AtomIdx(3));
assert!(pref.is_some(), "amine C-C-N-C should have preference");
if let Some(p) = pref {
assert_eq!(p.angle_deg, 180.0);
}
}
#[test]
fn test_phenyl_ketone_torsion_preference() {
let mol = parse("c1ccccc1C(=O)C").unwrap(); let c_carbonyl_idx = (0..mol.atom_count() as u32)
.map(AtomIdx)
.find(|&i| classify_atom_type(&mol, i) == AtomType::CCarbonyl);
assert!(
c_carbonyl_idx.is_some(),
"acetophenone should have a carbonyl C"
);
}
#[test]
fn test_score_torsion_disulfide_at_90() {
let pref = TorsionPreference {
angle_deg: 90.0,
penalty_per_degree: 0.1,
};
let score = score_torsion(90.0, &pref);
assert!(score.abs() < 1e-6, "at preferred angle score should be 0");
let score_off = score_torsion(90.0 + 20.0, &pref);
assert!((score_off - 2.0).abs() < 1e-6);
}
#[test]
fn test_pattern_count_covers_20_plus() {
let mol_alkane = parse("CCCC").unwrap();
let mol_biphenyl = parse("c1ccccc1-c1ccccc1").unwrap();
let mol_amide = parse("CC(=O)N").unwrap();
let mol_ester = parse("CC(=O)OC").unwrap();
let mol_disulfide = parse("CSSC").unwrap();
let mol_nitrile = parse("CCC#N").unwrap();
let mol_amine = parse("CCNC").unwrap();
let cases = [
(&mol_alkane, AtomIdx(0), AtomIdx(1), AtomIdx(2), AtomIdx(3)),
(
&mol_biphenyl,
AtomIdx(1),
AtomIdx(0),
AtomIdx(6),
AtomIdx(7),
),
(
&mol_disulfide,
AtomIdx(0),
AtomIdx(1),
AtomIdx(2),
AtomIdx(3),
),
(&mol_nitrile, AtomIdx(0), AtomIdx(1), AtomIdx(2), AtomIdx(3)),
(&mol_amine, AtomIdx(0), AtomIdx(1), AtomIdx(2), AtomIdx(3)),
];
for (mol, a, b, c, d) in &cases {
let pref = get_torsion_preference(mol, *a, *b, *c, *d);
let _ = pref; }
let pref_amide =
get_torsion_preference(&mol_amide, AtomIdx(0), AtomIdx(1), AtomIdx(2), AtomIdx(2));
let _ = pref_amide;
let pref_ester =
get_torsion_preference(&mol_ester, AtomIdx(0), AtomIdx(1), AtomIdx(2), AtomIdx(3));
let _ = pref_ester;
}
#[test]
fn test_atom_type_furan_oxygen() {
let mol = parse("c1ccco1").unwrap();
let o_idx = (0..mol.atom_count() as u32)
.map(AtomIdx)
.find(|&i| mol.atom(i).element.atomic_number() == 8)
.expect("furan must have an oxygen atom");
assert_eq!(
classify_atom_type(&mol, o_idx),
AtomType::OAromatic,
"furan O should be OAromatic"
);
}
#[test]
fn test_atom_type_thiophene_sulfur() {
let mol = parse("c1cccs1").unwrap();
let s_idx = (0..mol.atom_count() as u32)
.map(AtomIdx)
.find(|&i| mol.atom(i).element.atomic_number() == 16)
.expect("thiophene must have a sulfur atom");
assert_eq!(
classify_atom_type(&mol, s_idx),
AtomType::SAromatic,
"thiophene S should be SAromatic"
);
}
#[test]
fn test_furanyl_biaryl_prefers_planar() {
let mol = parse("c1ccc(-c2ccco2)cc1").unwrap();
let o_idx = (0..mol.atom_count() as u32)
.map(AtomIdx)
.find(|&i| mol.atom(i).element.atomic_number() == 8)
.expect("must have O");
let o_neighbor = mol
.neighbors(o_idx)
.next()
.map(|(n, _)| n)
.expect("O has neighbors");
assert_eq!(classify_atom_type(&mol, o_idx), AtomType::OAromatic);
assert_eq!(classify_atom_type(&mol, o_neighbor), AtomType::CAromatic);
let pref = get_torsion_preference(&mol, o_idx, o_idx, o_neighbor, o_neighbor);
let pref2 = get_torsion_preference(&mol, AtomIdx(0), o_idx, o_neighbor, AtomIdx(0));
assert!(
pref.is_some() || pref2.is_some(),
"OAromatic–CAromatic should have a torsion preference"
);
}
#[test]
fn test_morpholine_gauche_preference() {
let mol = parse("C1CNCCO1").unwrap();
let n_idx = (0..mol.atom_count() as u32)
.map(AtomIdx)
.find(|&i| mol.atom(i).element.atomic_number() == 7)
.expect("morpholine must have N");
let o_idx = (0..mol.atom_count() as u32)
.map(AtomIdx)
.find(|&i| mol.atom(i).element.atomic_number() == 8)
.expect("morpholine must have O");
let pref = get_torsion_preference(&mol, n_idx, AtomIdx(3), AtomIdx(4), o_idx);
assert!(
pref.is_some(),
"morpholine N-C-C-O should have gauche preference"
);
if let Some(p) = pref {
assert_eq!(p.angle_deg, 60.0, "morpholine N-C-C-O prefers 60°");
}
}
}