Skip to main content

chematic_perception/
pharmacophore.rs

1//! Pharmacophore feature detection and fingerprinting.
2//!
3//! Pharmacophore features are abstract representations of functional groups important
4//! for drug binding. This module detects: donors, acceptors, aromatic, hydrophobic,
5//! positive, and negative features.
6
7use chematic_core::{AtomIdx, Molecule};
8
9/// Pharmacophore feature types.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum FeatureType {
12    /// Hydrogen bond donor (N-H, O-H)
13    Donor,
14    /// Hydrogen bond acceptor (N, O with lone pairs)
15    Acceptor,
16    /// Aromatic ring
17    Aromatic,
18    /// Hydrophobic (aliphatic C, Br, Cl, I)
19    Hydrophobic,
20    /// Positive charge (N+, guanidinium)
21    Positive,
22    /// Negative charge (O-, S-)
23    Negative,
24}
25
26/// A detected pharmacophore feature in a molecule.
27#[derive(Debug, Clone)]
28pub struct Feature {
29    /// Type of pharmacophore feature
30    pub ftype: FeatureType,
31    /// Primary atom index
32    pub atom: AtomIdx,
33    /// Secondary atoms (e.g., all atoms in aromatic ring)
34    pub neighbors: Vec<AtomIdx>,
35}
36
37/// Detect all pharmacophore features in a molecule.
38pub fn detect_features(mol: &Molecule) -> Vec<Feature> {
39    let mut features = Vec::new();
40
41    for idx in 0..mol.atom_count() {
42        let atom_idx = AtomIdx(idx as u32);
43        let atom = mol.atom(atom_idx);
44
45        match atom.element {
46            chematic_core::Element::N => {
47                // N is donor (implicit hydrogens)
48                if atom.charge == 0 {
49                    features.push(Feature {
50                        ftype: FeatureType::Donor,
51                        atom: atom_idx,
52                        neighbors: vec![],
53                    });
54                }
55
56                // N acceptor (lone pair)
57                if !atom.aromatic {
58                    features.push(Feature {
59                        ftype: FeatureType::Acceptor,
60                        atom: atom_idx,
61                        neighbors: vec![],
62                    });
63                }
64
65                // N+ positive
66                if atom.charge > 0 {
67                    features.push(Feature {
68                        ftype: FeatureType::Positive,
69                        atom: atom_idx,
70                        neighbors: vec![],
71                    });
72                }
73            }
74            chematic_core::Element::O => {
75                // O is donor (O-H implicit)
76                features.push(Feature {
77                    ftype: FeatureType::Donor,
78                    atom: atom_idx,
79                    neighbors: vec![],
80                });
81
82                // O acceptor (lone pair)
83                features.push(Feature {
84                    ftype: FeatureType::Acceptor,
85                    atom: atom_idx,
86                    neighbors: vec![],
87                });
88
89                // O- negative
90                if atom.charge < 0 {
91                    features.push(Feature {
92                        ftype: FeatureType::Negative,
93                        atom: atom_idx,
94                        neighbors: vec![],
95                    });
96                }
97            }
98            chematic_core::Element::C => {
99                // Aromatic carbon
100                if atom.aromatic {
101                    let ring_atoms = collect_aromatic_ring(mol, atom_idx);
102                    if !ring_atoms.is_empty() {
103                        features.push(Feature {
104                            ftype: FeatureType::Aromatic,
105                            atom: atom_idx,
106                            neighbors: ring_atoms,
107                        });
108                    }
109                }
110
111                // Hydrophobic aliphatic C
112                if !atom.aromatic {
113                    features.push(Feature {
114                        ftype: FeatureType::Hydrophobic,
115                        atom: atom_idx,
116                        neighbors: vec![],
117                    });
118                }
119            }
120            chematic_core::Element::BR | chematic_core::Element::CL | chematic_core::Element::I => {
121                // Halides are hydrophobic
122                features.push(Feature {
123                    ftype: FeatureType::Hydrophobic,
124                    atom: atom_idx,
125                    neighbors: vec![],
126                });
127            }
128            chematic_core::Element::S => {
129                // Sulfur: donor (S-H) and acceptor
130                let h_count = count_hydrogens(mol, atom_idx);
131                if h_count > 0 {
132                    features.push(Feature {
133                        ftype: FeatureType::Donor,
134                        atom: atom_idx,
135                        neighbors: vec![],
136                    });
137                }
138                features.push(Feature {
139                    ftype: FeatureType::Acceptor,
140                    atom: atom_idx,
141                    neighbors: vec![],
142                });
143            }
144            _ => {}
145        }
146    }
147
148    features
149}
150
151/// Count hydrogen atoms bonded to a given atom.
152fn count_hydrogens(mol: &Molecule, atom: AtomIdx) -> u8 {
153    (0..mol.atom_count())
154        .filter(|&i| {
155            let idx = AtomIdx(i as u32);
156            mol.atom(idx).element == chematic_core::Element::H
157                && mol.bond_between(atom, idx).is_some()
158        })
159        .count() as u8
160}
161
162/// Collect all atoms in the aromatic ring containing the given atom.
163fn collect_aromatic_ring(mol: &Molecule, start: AtomIdx) -> Vec<AtomIdx> {
164    if !mol.atom(start).aromatic {
165        return vec![];
166    }
167
168    let mut ring = vec![start];
169    let mut visited = std::collections::HashSet::from([start]);
170    let mut queue = std::collections::VecDeque::from([start]);
171
172    while let Some(current) = queue.pop_front() {
173        for neighbor_idx in 0..mol.atom_count() {
174            let neighbor = AtomIdx(neighbor_idx as u32);
175            if visited.contains(&neighbor) {
176                continue;
177            }
178
179            if mol.bond_between(current, neighbor).is_some() && mol.atom(neighbor).aromatic {
180                visited.insert(neighbor);
181                queue.push_back(neighbor);
182                ring.push(neighbor);
183            }
184        }
185    }
186
187    ring.sort_by_key(|a| a.0);
188    ring
189}
190
191/// Generate a pharmacophore fingerprint from detected features.
192/// Returns a bit vector where each bit represents a feature type at a specific atom.
193pub fn features_to_bitvec(features: &[Feature]) -> u64 {
194    let mut bits = 0u64;
195
196    for (i, feature) in features.iter().enumerate() {
197        if i >= 8 {
198            break; // Limit to 8 features per molecule
199        }
200
201        let feature_bits = match feature.ftype {
202            FeatureType::Donor => 0b001,
203            FeatureType::Acceptor => 0b010,
204            FeatureType::Aromatic => 0b011,
205            FeatureType::Hydrophobic => 0b100,
206            FeatureType::Positive => 0b101,
207            FeatureType::Negative => 0b110,
208        };
209
210        bits |= (feature_bits as u64) << (i * 3);
211    }
212
213    bits
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use chematic_smiles::parse;
220
221    #[test]
222    fn test_benzene_aromatic() {
223        let mol = parse("c1ccccc1").unwrap();
224        let features = detect_features(&mol);
225        let aromatic_count = features
226            .iter()
227            .filter(|f| f.ftype == FeatureType::Aromatic)
228            .count();
229        assert!(aromatic_count > 0, "benzene should have aromatic feature");
230    }
231
232    #[test]
233    fn test_ethanol_donor_acceptor() {
234        let mol = parse("CCO").unwrap();
235        let features = detect_features(&mol);
236        let has_donor = features.iter().any(|f| f.ftype == FeatureType::Donor);
237        let has_acceptor = features.iter().any(|f| f.ftype == FeatureType::Acceptor);
238        assert!(has_donor, "ethanol should have donor (O-H)");
239        assert!(has_acceptor, "ethanol should have acceptor (O)");
240    }
241
242    #[test]
243    fn test_aniline_donor_aromatic() {
244        let mol = parse("Nc1ccccc1").unwrap();
245        let features = detect_features(&mol);
246        let has_donor = features.iter().any(|f| f.ftype == FeatureType::Donor);
247        let has_aromatic = features.iter().any(|f| f.ftype == FeatureType::Aromatic);
248        assert!(has_donor, "aniline should have N-H donor");
249        assert!(has_aromatic, "aniline should have aromatic rings");
250    }
251}