Skip to main content

chematic_ff/
mmff94_advanced.rs

1//! B5 Advanced: MMFF94 batch processing, ensemble calculations, and advanced electrostatics.
2//!
3//! Provides high-performance operations for:
4//! - Batch molecular property calculations
5//! - Molecular ensemble properties (multiple conformers)
6//! - Full electrostatic scaling matrices
7//! - Cached/vectorized computations
8
9use chematic_core::{AtomIdx, Molecule};
10
11use crate::mmff94::MMFF94Type;
12use crate::mmff94_params::mmff94_electrostatic_scaling_1_4;
13
14/// Electrostatic scaling matrix for a complete molecule.
15/// Stores 1-4 and 1-3 scaling factors for all atom pairs.
16#[derive(Clone, Debug)]
17pub struct ElectrostaticMatrix {
18    /// Scaling factors: matrix[i][j] contains dielectric scaling from atom i to j
19    pub scaling: Vec<Vec<f64>>,
20    /// 1-4 pair indicators: true if atoms are 1-4 pairs (through 3 bonds)
21    pub is_1_4_pair: Vec<Vec<bool>>,
22    /// 1-3 pair indicators: true if atoms are bonded through one atom
23    pub is_1_3_pair: Vec<Vec<bool>>,
24}
25
26impl ElectrostaticMatrix {
27    /// Create an electrostatic scaling matrix for a molecule.
28    ///
29    /// Computes all pairwise dielectric scaling factors based on:
30    /// - Topological distance (1-2, 1-3, 1-4, farther)
31    /// - Atom types and bonding patterns
32    pub fn new(mol: &Molecule, mmff94_types: &[MMFF94Type]) -> Self {
33        let n = mol.atom_count();
34        let mut scaling = vec![vec![4.0; n]; n]; // Default dielectric
35        let mut is_1_4_pair = vec![vec![false; n]; n];
36        let mut is_1_3_pair = vec![vec![false; n]; n];
37
38        // Build adjacency matrix
39        let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
40        for (_, bond) in mol.bonds() {
41            let i = bond.atom1.0 as usize;
42            let j = bond.atom2.0 as usize;
43            adj[i].push(j);
44            adj[j].push(i);
45        }
46
47        // Compute topological distances
48        let dist = topological_distance_matrix(mol);
49
50        // Fill scaling matrix
51        for i in 0..n {
52            for j in 0..n {
53                if i == j {
54                    scaling[i][j] = 0.0; // Self-interaction (not used)
55                    continue;
56                }
57
58                let d = dist[i][j];
59
60                if d == 1 {
61                    // 1-2 pairs: typically excluded
62                    scaling[i][j] = 0.0;
63                } else if d == 2 {
64                    // 1-3 pairs: excluded or reduced
65                    is_1_3_pair[i][j] = true;
66                    scaling[i][j] = 0.0; // Usually excluded
67                } else if d == 3 {
68                    // 1-4 pairs: scaled
69                    is_1_4_pair[i][j] = true;
70                    let type_i = mmff94_types[i];
71                    let type_j = mmff94_types[j];
72                    let params = mmff94_electrostatic_scaling_1_4(type_i, type_j);
73                    scaling[i][j] = params.scale_dielectric;
74                } else {
75                    // Farther pairs: full electrostatic
76                    scaling[i][j] = 4.0; // Default organic environment
77                }
78            }
79        }
80
81        ElectrostaticMatrix {
82            scaling,
83            is_1_4_pair,
84            is_1_3_pair,
85        }
86    }
87
88    /// Get scaling factor for atom pair (i, j)
89    pub fn get_scaling(&self, i: usize, j: usize) -> f64 {
90        if i < self.scaling.len() && j < self.scaling[i].len() {
91            self.scaling[i][j]
92        } else {
93            4.0 // Default
94        }
95    }
96
97    /// Check if atoms are 1-4 pairs
98    pub fn is_1_4(&self, i: usize, j: usize) -> bool {
99        if i < self.is_1_4_pair.len() && j < self.is_1_4_pair[i].len() {
100            self.is_1_4_pair[i][j]
101        } else {
102            false
103        }
104    }
105}
106
107/// Batch properties for multiple molecules.
108///
109/// Enables efficient calculation of properties for molecular ensembles
110/// (e.g., multiple conformers, related molecules).
111#[derive(Clone, Debug)]
112pub struct MMFF94BatchProperties {
113    /// Per-molecule charges
114    pub charges: Vec<Vec<f64>>,
115    /// Per-molecule bond dipoles
116    pub bond_dipoles: Vec<Vec<(usize, usize, f64)>>,
117    /// Per-molecule electrostatic matrices
118    pub electrostatic_matrices: Vec<ElectrostaticMatrix>,
119}
120
121impl MMFF94BatchProperties {
122    /// Create empty batch properties
123    pub fn new() -> Self {
124        MMFF94BatchProperties {
125            charges: Vec::new(),
126            bond_dipoles: Vec::new(),
127            electrostatic_matrices: Vec::new(),
128        }
129    }
130
131    /// Get total size (number of molecules in batch)
132    pub fn len(&self) -> usize {
133        self.charges.len()
134    }
135
136    /// Check if batch is empty
137    pub fn is_empty(&self) -> bool {
138        self.charges.is_empty()
139    }
140
141    /// Add properties for a new molecule
142    pub fn push(
143        &mut self,
144        charges: Vec<f64>,
145        bond_dipoles: Vec<(usize, usize, f64)>,
146        electrostatic_matrix: ElectrostaticMatrix,
147    ) {
148        self.charges.push(charges);
149        self.bond_dipoles.push(bond_dipoles);
150        self.electrostatic_matrices.push(electrostatic_matrix);
151    }
152
153    /// Calculate average charges across all molecules in batch
154    pub fn average_charges(&self) -> Vec<f64> {
155        if self.charges.is_empty() {
156            return Vec::new();
157        }
158
159        let n_atoms = self.charges[0].len();
160        let mut avg = vec![0.0; n_atoms];
161
162        for charges in &self.charges {
163            for (i, &q) in charges.iter().enumerate() {
164                avg[i] += q;
165            }
166        }
167
168        let n_mols = self.charges.len() as f64;
169        for q in &mut avg {
170            *q /= n_mols;
171        }
172
173        avg
174    }
175
176    /// Calculate charge variance across molecules
177    pub fn charge_variance(&self) -> Vec<f64> {
178        if self.charges.is_empty() {
179            return Vec::new();
180        }
181
182        let n_atoms = self.charges[0].len();
183        let avg = self.average_charges();
184        let mut var = vec![0.0; n_atoms];
185
186        for charges in &self.charges {
187            for (i, &q) in charges.iter().enumerate() {
188                let diff = q - avg[i];
189                var[i] += diff * diff;
190            }
191        }
192
193        let n_mols = self.charges.len() as f64;
194        for v in &mut var {
195            *v /= n_mols;
196            *v = v.sqrt(); // Standard deviation
197        }
198
199        var
200    }
201}
202
203impl Default for MMFF94BatchProperties {
204    fn default() -> Self {
205        Self::new()
206    }
207}
208
209/// Compute topological distance matrix (BFS-based).
210fn topological_distance_matrix(mol: &Molecule) -> Vec<Vec<i32>> {
211    let n = mol.atom_count();
212    let mut dist = vec![vec![i32::MAX; n]; n];
213
214    // Initialize
215    for (i, row) in dist.iter_mut().enumerate().take(n) {
216        row[i] = 0;
217    }
218
219    // BFS from each atom
220    for start in 0..n {
221        let mut queue = vec![start];
222        let mut visited = vec![false; n];
223        visited[start] = true;
224
225        while !queue.is_empty() {
226            let current = queue.remove(0);
227            let atom_idx = AtomIdx(current as u32);
228
229            for (neighbor, _) in mol.neighbors(atom_idx) {
230                let neighbor_idx = neighbor.0 as usize;
231                if !visited[neighbor_idx] {
232                    visited[neighbor_idx] = true;
233                    dist[start][neighbor_idx] = dist[start][current] + 1;
234                    queue.push(neighbor_idx);
235                }
236            }
237        }
238    }
239
240    dist
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::mmff94::assign_mmff94_types;
247    use chematic_smiles::parse;
248
249    #[test]
250    fn test_electrostatic_matrix_creation() {
251        let mol = parse("CC").unwrap();
252        let mmff94_types = assign_mmff94_types(&mol).unwrap();
253
254        let matrix = ElectrostaticMatrix::new(&mol, &mmff94_types);
255        assert_eq!(matrix.scaling.len(), 2);
256        assert_eq!(matrix.scaling[0].len(), 2);
257    }
258
259    #[test]
260    fn test_electrostatic_matrix_1_2_exclusion() {
261        let mol = parse("CC").unwrap();
262        let mmff94_types = assign_mmff94_types(&mol).unwrap();
263
264        let matrix = ElectrostaticMatrix::new(&mol, &mmff94_types);
265        // 1-2 pairs (bonded) should be excluded
266        assert_eq!(matrix.get_scaling(0, 1), 0.0);
267        assert_eq!(matrix.get_scaling(1, 0), 0.0);
268    }
269
270    #[test]
271    fn test_batch_properties_empty() {
272        let batch = MMFF94BatchProperties::new();
273        assert!(batch.is_empty());
274        assert_eq!(batch.len(), 0);
275    }
276
277    #[test]
278    fn test_batch_properties_average() {
279        let mut batch = MMFF94BatchProperties::new();
280
281        let mol = parse("C").unwrap();
282        let mmff94_types = assign_mmff94_types(&mol).unwrap();
283        let matrix = ElectrostaticMatrix::new(&mol, &mmff94_types);
284
285        // Add two identical molecules with different charges
286        batch.push(vec![0.1], vec![], matrix.clone());
287        batch.push(vec![0.3], vec![], matrix);
288
289        let avg = batch.average_charges();
290        assert_eq!(avg.len(), 1);
291        assert!((avg[0] - 0.2).abs() < 1e-6); // (0.1 + 0.3) / 2
292    }
293
294    #[test]
295    fn test_batch_properties_variance() {
296        let mut batch = MMFF94BatchProperties::new();
297
298        let mol = parse("C").unwrap();
299        let mmff94_types = assign_mmff94_types(&mol).unwrap();
300        let matrix = ElectrostaticMatrix::new(&mol, &mmff94_types);
301
302        batch.push(vec![0.0], vec![], matrix.clone());
303        batch.push(vec![1.0], vec![], matrix);
304
305        let var = batch.charge_variance();
306        assert_eq!(var.len(), 1);
307        // Variance of [0.0, 1.0] around mean 0.5
308        // σ = sqrt(((0-0.5)² + (1-0.5)²) / 2) = sqrt(0.25) = 0.5
309        assert!((var[0] - 0.5).abs() < 1e-6);
310    }
311
312    #[test]
313    fn test_topological_distance_ethane() {
314        let mol = parse("CC").unwrap();
315        let dist = topological_distance_matrix(&mol);
316
317        assert_eq!(dist[0][0], 0);
318        assert_eq!(dist[1][1], 0);
319        assert_eq!(dist[0][1], 1);
320        assert_eq!(dist[1][0], 1);
321    }
322
323    #[test]
324    fn test_topological_distance_propane() {
325        let mol = parse("CCC").unwrap();
326        let dist = topological_distance_matrix(&mol);
327
328        // C-C-C structure
329        assert_eq!(dist[0][1], 1); // 1-2
330        assert_eq!(dist[1][2], 1); // 1-2
331        assert_eq!(dist[0][2], 2); // 1-3 (through one atom)
332    }
333
334    #[test]
335    fn test_electrostatic_matrix_scaling_values() {
336        let mol = parse("CCCC").unwrap();
337        let mmff94_types = assign_mmff94_types(&mol).unwrap();
338
339        let matrix = ElectrostaticMatrix::new(&mol, &mmff94_types);
340
341        // Check some expected scaling patterns
342        for i in 0..4 {
343            // Diagonal should be zero
344            assert_eq!(matrix.get_scaling(i, i), 0.0);
345        }
346
347        // All other scaling should be non-negative
348        for i in 0..4 {
349            for j in 0..4 {
350                assert!(matrix.get_scaling(i, j) >= 0.0);
351            }
352        }
353    }
354}