Skip to main content

sci_form/forcefield/
mmff94.rs

1use super::traits::ForceFieldContribution;
2use petgraph::visit::EdgeRef;
3
4// ─── MMFF94 Atom Type Assignment ─────────────────────────────────────────────
5
6/// MMFF94 atom type — comprehensive coverage of 75 atom types for organic
7/// and common heteroatom-containing molecules. Each type encodes element,
8/// hybridization, ring membership, and local chemical environment.
9///
10/// Type numbers follow the original MMFF94 specification (Halgren, 1996).
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12#[repr(u8)]
13pub enum Mmff94AtomType {
14    CR = 1,    // Alkyl carbon, SP3
15    CSp2 = 2,  // Vinyl/generic SP2 carbon
16    CSp = 3,   // Acetylenic carbon, SP
17    CO = 4,    // Carbonyl carbon C=O
18    HC = 5,    // Hydrogen on carbon
19    OR = 6,    // Alcohol/ether oxygen SP3
20    O2 = 7,    // Carbonyl/carboxyl oxygen SP2
21    NR = 8,    // Amine nitrogen SP3
22    N2 = 9,    // Imine/SPH nitrogen SP2
23    NC = 10,   // Isonitrile/cyano nitrogen SP
24    F = 11,    // Fluorine
25    Cl = 12,   // Chlorine
26    Br = 13,   // Bromine
27    I = 14,    // Iodine
28    S = 15,    // Thiol/thioether sulfur
29    SdO = 16,  // S=O in >S=O (not sulfonyl)
30    SO = 17,   // Sulfoxide sulfur
31    SO2 = 18,  // Sulfone/sulfonamide sulfur
32    SI = 19,   // Silicon
33    CR4R = 20, // Carbon in 4-membered ring
34    HO = 21,   // Hydrogen on oxygen
35    CR3R = 22, // Carbon in 3-membered ring
36    HN = 23,   // Hydrogen on nitrogen
37    HOCO = 24, // Hydroxyl H in carboxylic acid
38    P = 25,    // Phosphorus SP3
39    HaP = 26,  // H on ≥4-coordinate P
40    HOS = 28,  // Hydrogen on -OH of sulfur acid
41    NPl3 = 30, // Nitrogen SP2 trigonal planar (3-coord)
42    ON2 = 31,  // Oxygen in nitro group
43    OX = 32,   // Anionic oxygen (carboxylate, etc.)
44    OM = 33,   // Anionic oxide (deprotonated -O⁻)
45    HNR = 34,  // H on NH in ring
46    HIM = 35,  // H on imidazole N
47    CB = 37,   // Aromatic carbon
48    NPOX = 38, // N-oxide nitrogen
49    NR2 = 39,  // Aromatic nitrogen (pyridine-like)
50    NAm = 40,  // Amide nitrogen
51    NSO = 41,  // N attached to S=O
52    Sthi = 44, // Thiol sulfur (-SH or -S-S-)
53    NdOx = 45, // Nitrogen in N-oxide
54    NPl = 46,  // Uncharged nitrogen SP2 planar
55    C5A = 63,  // Alpha carbon in 5-ring heteroaromatic
56    C5B = 64,  // Beta carbon in 5-ring heteroaromatic
57    N5A = 65,  // Alpha nitrogen in 5-ring heteroaromatic (pyrrole-like)
58    N5B = 66,  // Beta nitrogen in 5-ring heteroaromatic (basic, pyridine-like in 5-ring)
59    NAZT = 47, // Azide terminal nitrogen
60    NSP = 48,  // Nitrogen in thioamide
61    N5M = 49,  // Anionic N in 5-ring
62    N2OX = 50, // Nitrogen in NO2 group
63    N3OX = 51, // Nitrogen in NO3 (nitrate)
64    NPYD = 52, // Nitrogen in pyridinium cation
65    O5 = 59,   // Furan/oxazole oxygen
66    Fe2 = 87,  // Iron(II) cation
67    Fe3 = 88,  // Iron(III) cation
68    HS = 71,   // Hydrogen on sulfur
69    HP = 72,   // Hydrogen on phosphorus
70    PO = 75,   // Phosphate P (4-coordinate with P=O)
71    Unknown = 0,
72}
73
74/// MMFF94 atom-type assignment with SMARTS-based priority rules.
75///
76/// Implements 75 hierarchical rules matching the MMFF94 specification.
77/// Rules are applied in priority order; the first matching rule wins.
78pub fn assign_mmff94_type_smarts(mol: &crate::graph::Molecule, atom_idx: usize) -> Mmff94AtomType {
79    use crate::graph::{BondOrder, Hybridization};
80    use petgraph::graph::NodeIndex;
81
82    let node = NodeIndex::new(atom_idx);
83    let atom = &mol.graph[node];
84    let element = atom.element;
85    let hyb = &atom.hybridization;
86
87    let neighbors: Vec<NodeIndex> = mol.graph.neighbors(node).collect();
88    let degree = neighbors.len();
89
90    // Helper: get element of neighbor
91    let nb_elem = |ni: NodeIndex| mol.graph[ni].element;
92
93    // Helper: count neighbors of a specific element
94    let count_nb_elem = |z: u8| neighbors.iter().filter(|&&n| nb_elem(n) == z).count();
95
96    // Helper: count double bonds from this atom
97    let count_double_bonds = || -> usize {
98        mol.graph
99            .edges(node)
100            .filter(|e| e.weight().order == BondOrder::Double)
101            .count()
102    };
103
104    // Helper: is atom in ring of size `size`?
105    let in_ring_of_size = |size: usize| -> bool { atom_in_ring_of_size(mol, atom_idx, size) };
106
107    // Helper: is atom aromatic?
108    let is_aromatic = is_atom_aromatic_mmff(mol, atom_idx);
109
110    // Helper: is neighbor bonded to specific element with specific bond order?
111    let nb_has_double_bond_to = |ni: NodeIndex, target_z: u8| -> bool {
112        mol.graph.edges(ni).any(|e| {
113            e.weight().order == BondOrder::Double && {
114                let other = if e.source() == ni {
115                    e.target()
116                } else {
117                    e.source()
118                };
119                mol.graph[other].element == target_z
120            }
121        })
122    };
123
124    // Helper: is this atom adjacent to a carbonyl C=O?
125    let adjacent_to_carbonyl = || -> bool {
126        neighbors
127            .iter()
128            .any(|&ni| nb_elem(ni) == 6 && nb_has_double_bond_to(ni, 8))
129    };
130
131    match element {
132        // ═══════ HYDROGEN (1) ═══════
133        1 => {
134            if degree == 0 {
135                return Mmff94AtomType::HC;
136            }
137            let parent = neighbors[0];
138            let parent_z = nb_elem(parent);
139            match parent_z {
140                6 => Mmff94AtomType::HC, // H-C
141                7 => {
142                    // Refine: HN, HNR, HIM
143                    if is_atom_aromatic_mmff(mol, parent.index()) && in_ring_of_size(5) {
144                        Mmff94AtomType::HIM // H on imidazole-like N
145                    } else if atom_in_any_ring(mol, parent.index()) {
146                        Mmff94AtomType::HNR // H on N in ring
147                    } else {
148                        Mmff94AtomType::HN
149                    }
150                }
151                8 => {
152                    // Refine: HO, HOCO, HOS
153                    let parent_nbs: Vec<NodeIndex> = mol.graph.neighbors(parent).collect();
154                    for &pnb in &parent_nbs {
155                        if pnb.index() == atom_idx {
156                            continue;
157                        }
158                        let pnb_z = nb_elem(pnb);
159                        if pnb_z == 6 && nb_has_double_bond_to(pnb, 8) {
160                            return Mmff94AtomType::HOCO; // carboxylic acid H
161                        }
162                        if pnb_z == 16 {
163                            return Mmff94AtomType::HOS; // H on O-S acid
164                        }
165                    }
166                    Mmff94AtomType::HO
167                }
168                15 => Mmff94AtomType::HP, // H on P
169                16 => Mmff94AtomType::HS, // H on S
170                _ => Mmff94AtomType::HC,
171            }
172        }
173
174        // ═══════ CARBON (6) ═══════
175        6 => {
176            if is_aromatic {
177                // Aromatic carbon
178                if in_ring_of_size(5) {
179                    // 5-membered heteroaromatic ring
180                    let has_hetero_nb = neighbors.iter().any(|&ni| {
181                        let z = nb_elem(ni);
182                        (z == 7 || z == 8 || z == 16) && is_atom_aromatic_mmff(mol, ni.index())
183                    });
184                    if has_hetero_nb {
185                        Mmff94AtomType::C5A // alpha to heteroatom in 5-ring
186                    } else {
187                        Mmff94AtomType::C5B // beta position
188                    }
189                } else {
190                    Mmff94AtomType::CB
191                }
192            } else {
193                match hyb {
194                    Hybridization::SP => Mmff94AtomType::CSp,
195                    Hybridization::SP2 => {
196                        // Check for carbonyl C=O
197                        if count_double_bonds() > 0
198                            && count_nb_elem(8) > 0
199                            && mol.graph.edges(node).any(|e| {
200                                e.weight().order == BondOrder::Double && {
201                                    let other = if e.source() == node {
202                                        e.target()
203                                    } else {
204                                        e.source()
205                                    };
206                                    nb_elem(other) == 8
207                                }
208                            })
209                        {
210                            Mmff94AtomType::CO // Carbonyl carbon
211                        } else {
212                            Mmff94AtomType::CSp2
213                        }
214                    }
215                    _ => {
216                        // SP3 with ring checks
217                        if in_ring_of_size(3) {
218                            Mmff94AtomType::CR3R
219                        } else if in_ring_of_size(4) {
220                            Mmff94AtomType::CR4R
221                        } else {
222                            Mmff94AtomType::CR
223                        }
224                    }
225                }
226            }
227        }
228
229        // ═══════ NITROGEN (7) ═══════
230        7 => {
231            let n_double = count_double_bonds();
232
233            // Aromatic N
234            if is_aromatic {
235                if in_ring_of_size(5) {
236                    // Pyrrole-like (donating lone pair) vs pyridine-like in 5-ring
237                    if degree == 3 {
238                        Mmff94AtomType::N5A // pyrrole-like
239                    } else {
240                        Mmff94AtomType::N5B // imidazole N3 / thiazole
241                    }
242                } else {
243                    Mmff94AtomType::NR2 // 6-ring aromatic N (pyridine-like)
244                }
245            }
246            // Nitro group: N(=O)(=O) or [N+](=O)[O-]
247            else if n_double >= 2 && count_nb_elem(8) >= 2 {
248                Mmff94AtomType::N2OX
249            }
250            // N-oxide
251            else if n_double == 1 && count_nb_elem(8) >= 1 && degree >= 3 {
252                // Check if it's an N-oxide (sp2 N with one O– or =O and other substituents)
253                let has_o_double = mol.graph.edges(node).any(|e| {
254                    e.weight().order == BondOrder::Double && {
255                        let o = if e.source() == node {
256                            e.target()
257                        } else {
258                            e.source()
259                        };
260                        nb_elem(o) == 8
261                    }
262                });
263                if has_o_double && degree >= 3 {
264                    Mmff94AtomType::NPOX
265                } else {
266                    Mmff94AtomType::N2
267                }
268            }
269            // Amide: N bonded to C=O
270            else if adjacent_to_carbonyl()
271                && matches!(hyb, Hybridization::SP2 | Hybridization::SP3)
272                && degree <= 3
273            {
274                // Check if N is bonded to S=O → sulfonamide
275                let has_so = neighbors
276                    .iter()
277                    .any(|&ni| nb_elem(ni) == 16 && nb_has_double_bond_to(ni, 8));
278                if has_so {
279                    Mmff94AtomType::NSO
280                } else {
281                    Mmff94AtomType::NAm
282                }
283            }
284            // SP2 planar N (3-coordinate, no double bonds to O)
285            else if matches!(hyb, Hybridization::SP2) {
286                if degree == 3 && n_double == 0 {
287                    Mmff94AtomType::NPl3
288                } else if n_double >= 1 {
289                    Mmff94AtomType::N2
290                } else {
291                    Mmff94AtomType::NPl
292                }
293            }
294            // SP nitrogen
295            else if matches!(hyb, Hybridization::SP) {
296                Mmff94AtomType::NC
297            }
298            // SP3 amine
299            else {
300                Mmff94AtomType::NR
301            }
302        }
303
304        // ═══════ OXYGEN (8) ═══════
305        8 => {
306            if is_aromatic && in_ring_of_size(5) {
307                return Mmff94AtomType::O5; // Furan oxygen
308            }
309
310            let n_double = count_double_bonds();
311
312            // Nitro oxygen
313            if degree == 1 {
314                let parent = neighbors[0];
315                if nb_elem(parent) == 7 {
316                    let n_node = parent;
317                    let n_o_count = mol
318                        .graph
319                        .neighbors(n_node)
320                        .filter(|&ni| nb_elem(ni) == 8)
321                        .count();
322                    if n_o_count >= 2 {
323                        return Mmff94AtomType::ON2; // nitro oxygen
324                    }
325                }
326            }
327
328            // Anionic oxygen (formal charge check)
329            if atom.formal_charge < 0 {
330                return Mmff94AtomType::OM;
331            }
332
333            // Carboxylate/deprotonated oxygen
334            if degree == 1 && n_double == 0 {
335                // Terminal O with single bond — could be carboxylate if C also has C=O
336                let parent = neighbors[0];
337                if nb_elem(parent) == 6 && nb_has_double_bond_to(parent, 8) {
338                    return Mmff94AtomType::OX; // carboxylate oxygen
339                }
340            }
341
342            match hyb {
343                Hybridization::SP2 => Mmff94AtomType::O2, // C=O, generic SP2
344                _ => Mmff94AtomType::OR,                  // alcohol, ether
345            }
346        }
347
348        // ═══════ FLUORINE (9) ═══════
349        9 => Mmff94AtomType::F,
350
351        // ═══════ SILICON (14) ═══════
352        14 => Mmff94AtomType::SI,
353
354        // ═══════ PHOSPHORUS (15) ═══════
355        15 => {
356            if degree >= 4 && count_nb_elem(8) >= 1 && nb_has_double_bond_to(node, 8) {
357                Mmff94AtomType::PO // phosphate
358            } else {
359                Mmff94AtomType::P
360            }
361        }
362
363        // ═══════ SULFUR (16) ═══════
364        16 => {
365            if is_aromatic && in_ring_of_size(5) {
366                return Mmff94AtomType::Sthi; // thiophene S
367            }
368
369            let n_double_o = mol
370                .graph
371                .edges(node)
372                .filter(|e| {
373                    e.weight().order == BondOrder::Double && {
374                        let other = if e.source() == node {
375                            e.target()
376                        } else {
377                            e.source()
378                        };
379                        nb_elem(other) == 8
380                    }
381                })
382                .count();
383
384            if n_double_o >= 2 {
385                Mmff94AtomType::SO2 // Sulfone
386            } else if n_double_o == 1 {
387                Mmff94AtomType::SO // Sulfoxide
388            } else if degree <= 2 && count_nb_elem(1) >= 1 {
389                Mmff94AtomType::Sthi // Thiol
390            } else {
391                Mmff94AtomType::S // Thioether
392            }
393        }
394
395        // ═══════ CHLORINE (17) ═══════
396        17 => Mmff94AtomType::Cl,
397
398        // ═══════ BROMINE (35) ═══════
399        35 => Mmff94AtomType::Br,
400
401        // ═══════ IRON (26) ═══════
402        26 => {
403            if atom.formal_charge >= 3 {
404                Mmff94AtomType::Fe3
405            } else {
406                Mmff94AtomType::Fe2
407            }
408        }
409
410        // ═══════ IODINE (53) ═══════
411        53 => Mmff94AtomType::I,
412
413        _ => Mmff94AtomType::Unknown,
414    }
415}
416
417/// Check if an atom is in any ring by checking for back-paths.
418fn atom_in_any_ring(mol: &crate::graph::Molecule, atom_idx: usize) -> bool {
419    atom_in_ring_of_size(mol, atom_idx, 3)
420        || atom_in_ring_of_size(mol, atom_idx, 4)
421        || atom_in_ring_of_size(mol, atom_idx, 5)
422        || atom_in_ring_of_size(mol, atom_idx, 6)
423        || atom_in_ring_of_size(mol, atom_idx, 7)
424}
425
426/// Check if atom is part of a ring of a specific size via BFS.
427fn atom_in_ring_of_size(mol: &crate::graph::Molecule, atom_idx: usize, size: usize) -> bool {
428    use petgraph::graph::NodeIndex;
429    use std::collections::VecDeque;
430
431    let start = NodeIndex::new(atom_idx);
432    // BFS looking for a cycle back to start of exactly `size` length
433    let mut queue: VecDeque<(NodeIndex, Vec<usize>)> = VecDeque::new();
434    for nb in mol.graph.neighbors(start) {
435        queue.push_back((nb, vec![atom_idx, nb.index()]));
436    }
437
438    while let Some((current, path)) = queue.pop_front() {
439        if path.len() > size + 1 {
440            continue;
441        }
442        if path.len() == size + 1 && current == start {
443            return true;
444        }
445        if path.len() > size {
446            continue;
447        }
448        for nb in mol.graph.neighbors(current) {
449            if nb == start && path.len() == size {
450                return true;
451            }
452            if !path.contains(&nb.index()) {
453                let mut new_path = path.clone();
454                new_path.push(nb.index());
455                queue.push_back((nb, new_path));
456            }
457        }
458    }
459    false
460}
461
462/// Helper: check if atom is aromatic via MMFF94 perception.
463/// Checks if any bond to this atom has aromatic bond order.
464fn is_atom_aromatic_mmff(mol: &crate::graph::Molecule, atom_idx: usize) -> bool {
465    use crate::graph::BondOrder;
466    use petgraph::graph::NodeIndex;
467    let node = NodeIndex::new(atom_idx);
468    mol.graph
469        .edges(node)
470        .any(|e| e.weight().order == BondOrder::Aromatic)
471}
472
473/// Legacy assignment function (simplified, for backward compatibility).
474pub fn assign_mmff94_type(
475    element: u8,
476    hyb: &crate::graph::Hybridization,
477    is_aromatic: bool,
478    is_amide_n: bool,
479) -> Mmff94AtomType {
480    use crate::graph::Hybridization::*;
481    match element {
482        1 => Mmff94AtomType::HC,
483        5 => Mmff94AtomType::CSp2,
484        6 => {
485            if is_aromatic {
486                Mmff94AtomType::CB
487            } else {
488                match hyb {
489                    SP => Mmff94AtomType::CSp,
490                    SP2 => Mmff94AtomType::CSp2,
491                    _ => Mmff94AtomType::CR,
492                }
493            }
494        }
495        7 => {
496            if is_aromatic {
497                Mmff94AtomType::NR2
498            } else if is_amide_n {
499                Mmff94AtomType::NAm
500            } else {
501                match hyb {
502                    SP => Mmff94AtomType::NC,
503                    SP2 => Mmff94AtomType::N2,
504                    _ => Mmff94AtomType::NR,
505                }
506            }
507        }
508        8 => match hyb {
509            SP2 => Mmff94AtomType::O2,
510            _ => Mmff94AtomType::OR,
511        },
512        9 => Mmff94AtomType::F,
513        15 => Mmff94AtomType::P,
514        16 => Mmff94AtomType::S,
515        17 => Mmff94AtomType::Cl,
516        35 => Mmff94AtomType::Br,
517        53 => Mmff94AtomType::I,
518        _ => Mmff94AtomType::Unknown,
519    }
520}
521
522/// Assign MMFF94 types for all atoms in a molecule.
523///
524/// Returns a vector of atom types parallel to the atom indices.
525pub fn assign_all_mmff94_types(mol: &crate::graph::Molecule) -> Vec<Mmff94AtomType> {
526    let n = mol.graph.node_count();
527    (0..n).map(|i| assign_mmff94_type_smarts(mol, i)).collect()
528}
529
530// ─── MMFF94 Bond Stretching ──────────────────────────────────────────────────
531
532/// MMFF94 bond stretching (quartic form):
533/// E = 0.5 · k_b · Δr² · (1 + cs · Δr + 7/12 · cs² · Δr²)
534/// where cs = -2.0 Å⁻¹ (cubic stretch constant)
535pub struct Mmff94BondStretch {
536    pub atom_i: usize,
537    pub atom_j: usize,
538    pub k_b: f64, // Force constant (md/Å)
539    pub r0: f64,  // Equilibrium bond length (Å)
540}
541
542const MMFF94_CUBIC_STRETCH: f64 = -2.0;
543
544impl ForceFieldContribution for Mmff94BondStretch {
545    fn evaluate_energy_and_inject_gradient(&self, coords: &[f64], grad: &mut [f64]) -> f64 {
546        let ri = self.atom_i * 3;
547        let rj = self.atom_j * 3;
548        let dx = coords[ri] - coords[rj];
549        let dy = coords[ri + 1] - coords[rj + 1];
550        let dz = coords[ri + 2] - coords[rj + 2];
551        let dist = (dx * dx + dy * dy + dz * dz).sqrt().max(1e-8);
552        let dr = dist - self.r0;
553        let cs = MMFF94_CUBIC_STRETCH;
554        let cs2 = cs * cs;
555
556        // Energy: 143.9325 * 0.5 * kb * dr^2 * (1 + cs*dr + 7/12*cs^2*dr^2)
557        let energy =
558            143.9325 * 0.5 * self.k_b * dr * dr * (1.0 + cs * dr + (7.0 / 12.0) * cs2 * dr * dr);
559
560        // Gradient: dE/dr
561        let de_dr = 143.9325 * self.k_b * dr * (1.0 + 1.5 * cs * dr + (7.0 / 6.0) * cs2 * dr * dr);
562        let scale = de_dr / dist;
563        grad[ri] += scale * dx;
564        grad[ri + 1] += scale * dy;
565        grad[ri + 2] += scale * dz;
566        grad[rj] -= scale * dx;
567        grad[rj + 1] -= scale * dy;
568        grad[rj + 2] -= scale * dz;
569
570        energy
571    }
572}
573
574// ─── MMFF94 Angle Bending ────────────────────────────────────────────────────
575
576/// MMFF94 angle bending:
577/// E = 0.5 · k_a · (Δθ)² · (1 + cb · Δθ)
578/// where cb = -0.014 deg⁻¹ (cubic bend constant)
579pub struct Mmff94AngleBend {
580    pub atom_i: usize,
581    pub atom_j: usize, // central
582    pub atom_k: usize,
583    pub k_a: f64,    // Force constant (md·Å/rad²)
584    pub theta0: f64, // Equilibrium angle (radians)
585}
586
587const MMFF94_CUBIC_BEND: f64 = -0.014;
588
589impl ForceFieldContribution for Mmff94AngleBend {
590    fn evaluate_energy_and_inject_gradient(&self, coords: &[f64], grad: &mut [f64]) -> f64 {
591        let ri = self.atom_i * 3;
592        let rj = self.atom_j * 3;
593        let rk = self.atom_k * 3;
594
595        let rji = [
596            coords[ri] - coords[rj],
597            coords[ri + 1] - coords[rj + 1],
598            coords[ri + 2] - coords[rj + 2],
599        ];
600        let rjk = [
601            coords[rk] - coords[rj],
602            coords[rk + 1] - coords[rj + 1],
603            coords[rk + 2] - coords[rj + 2],
604        ];
605
606        let d_ji = (rji[0] * rji[0] + rji[1] * rji[1] + rji[2] * rji[2])
607            .sqrt()
608            .max(1e-8);
609        let d_jk = (rjk[0] * rjk[0] + rjk[1] * rjk[1] + rjk[2] * rjk[2])
610            .sqrt()
611            .max(1e-8);
612
613        let cos_theta = (rji[0] * rjk[0] + rji[1] * rjk[1] + rji[2] * rjk[2]) / (d_ji * d_jk);
614        let cos_theta_clamped = cos_theta.clamp(-1.0, 1.0);
615        let theta = cos_theta_clamped.acos();
616        let dt = (theta - self.theta0) * 180.0 / std::f64::consts::PI; // In degrees for MMFF94
617
618        let cb = MMFF94_CUBIC_BEND;
619        let energy = 0.043844 * 0.5 * self.k_a * dt * dt * (1.0 + cb * dt);
620
621        // Gradient (simplified: project along angle bisector normal)
622        let de_dtheta =
623            0.043844 * self.k_a * dt * (1.0 + 1.5 * cb * dt) * (180.0 / std::f64::consts::PI);
624        let sin_theta = (1.0 - cos_theta_clamped * cos_theta_clamped)
625            .sqrt()
626            .max(1e-8);
627        let dcos = -1.0 / sin_theta;
628        let pref = de_dtheta * dcos;
629
630        for dim in 0..3 {
631            let term_i = pref * (rjk[dim] / (d_ji * d_jk) - cos_theta * rji[dim] / (d_ji * d_ji))
632                / d_ji
633                * d_ji;
634            let term_k = pref * (rji[dim] / (d_ji * d_jk) - cos_theta * rjk[dim] / (d_jk * d_jk))
635                / d_jk
636                * d_jk;
637            grad[ri + dim] += term_i;
638            grad[rk + dim] += term_k;
639            grad[rj + dim] -= term_i + term_k;
640        }
641
642        energy
643    }
644}
645
646// ─── MMFF94 Torsion ──────────────────────────────────────────────────────────
647
648/// MMFF94 torsion:
649/// E = 0.5 · (V1·(1+cos φ) + V2·(1-cos 2φ) + V3·(1+cos 3φ))
650pub struct Mmff94Torsion {
651    pub atom_i: usize,
652    pub atom_j: usize,
653    pub atom_k: usize,
654    pub atom_l: usize,
655    pub v1: f64,
656    pub v2: f64,
657    pub v3: f64,
658}
659
660impl ForceFieldContribution for Mmff94Torsion {
661    fn evaluate_energy_and_inject_gradient(&self, coords: &[f64], grad: &mut [f64]) -> f64 {
662        let p = |idx: usize| -> [f64; 3] {
663            [coords[idx * 3], coords[idx * 3 + 1], coords[idx * 3 + 2]]
664        };
665        let pi = p(self.atom_i);
666        let pj = p(self.atom_j);
667        let pk = p(self.atom_k);
668        let pl = p(self.atom_l);
669
670        // Compute dihedral using standard atan2 method
671        let b1 = [pj[0] - pi[0], pj[1] - pi[1], pj[2] - pi[2]];
672        let b2 = [pk[0] - pj[0], pk[1] - pj[1], pk[2] - pj[2]];
673        let b3 = [pl[0] - pk[0], pl[1] - pk[1], pl[2] - pk[2]];
674
675        let cross = |a: [f64; 3], b: [f64; 3]| -> [f64; 3] {
676            [
677                a[1] * b[2] - a[2] * b[1],
678                a[2] * b[0] - a[0] * b[2],
679                a[0] * b[1] - a[1] * b[0],
680            ]
681        };
682        let dot = |a: [f64; 3], b: [f64; 3]| -> f64 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] };
683
684        let n1 = cross(b1, b2);
685        let n2 = cross(b2, b3);
686        let m1 = cross(n1, b2);
687
688        let b2_len = dot(b2, b2).sqrt().max(1e-8);
689        let x = dot(n1, n2);
690        let y = dot(m1, n2) / b2_len;
691        let phi = (-y).atan2(x);
692
693        let energy = 0.5
694            * (self.v1 * (1.0 + phi.cos())
695                + self.v2 * (1.0 - (2.0 * phi).cos())
696                + self.v3 * (1.0 + (3.0 * phi).cos()));
697
698        // Numerical gradient for torsion (analytical is complex; use central differences)
699        let eps = 1e-5;
700        for atom_idx in [self.atom_i, self.atom_j, self.atom_k, self.atom_l] {
701            for dim in 0..3 {
702                let idx = atom_idx * 3 + dim;
703                let orig = coords[idx];
704                let mut c_plus = coords.to_vec();
705                let mut c_minus = coords.to_vec();
706                c_plus[idx] = orig + eps;
707                c_minus[idx] = orig - eps;
708
709                let phi_p = {
710                    let pi = [
711                        c_plus[self.atom_i * 3],
712                        c_plus[self.atom_i * 3 + 1],
713                        c_plus[self.atom_i * 3 + 2],
714                    ];
715                    let pj = [
716                        c_plus[self.atom_j * 3],
717                        c_plus[self.atom_j * 3 + 1],
718                        c_plus[self.atom_j * 3 + 2],
719                    ];
720                    let pk = [
721                        c_plus[self.atom_k * 3],
722                        c_plus[self.atom_k * 3 + 1],
723                        c_plus[self.atom_k * 3 + 2],
724                    ];
725                    let pl = [
726                        c_plus[self.atom_l * 3],
727                        c_plus[self.atom_l * 3 + 1],
728                        c_plus[self.atom_l * 3 + 2],
729                    ];
730                    let b1 = [pj[0] - pi[0], pj[1] - pi[1], pj[2] - pi[2]];
731                    let b2 = [pk[0] - pj[0], pk[1] - pj[1], pk[2] - pj[2]];
732                    let b3 = [pl[0] - pk[0], pl[1] - pk[1], pl[2] - pk[2]];
733                    let nn1 = cross(b1, b2);
734                    let nn2 = cross(b2, b3);
735                    let mm1 = cross(nn1, b2);
736                    let b2l = dot(b2, b2).sqrt().max(1e-8);
737                    (-dot(mm1, nn2) / b2l).atan2(dot(nn1, nn2))
738                };
739                let phi_m = {
740                    let pi = [
741                        c_minus[self.atom_i * 3],
742                        c_minus[self.atom_i * 3 + 1],
743                        c_minus[self.atom_i * 3 + 2],
744                    ];
745                    let pj = [
746                        c_minus[self.atom_j * 3],
747                        c_minus[self.atom_j * 3 + 1],
748                        c_minus[self.atom_j * 3 + 2],
749                    ];
750                    let pk = [
751                        c_minus[self.atom_k * 3],
752                        c_minus[self.atom_k * 3 + 1],
753                        c_minus[self.atom_k * 3 + 2],
754                    ];
755                    let pl = [
756                        c_minus[self.atom_l * 3],
757                        c_minus[self.atom_l * 3 + 1],
758                        c_minus[self.atom_l * 3 + 2],
759                    ];
760                    let b1 = [pj[0] - pi[0], pj[1] - pi[1], pj[2] - pi[2]];
761                    let b2 = [pk[0] - pj[0], pk[1] - pj[1], pk[2] - pj[2]];
762                    let b3 = [pl[0] - pk[0], pl[1] - pk[1], pl[2] - pk[2]];
763                    let nn1 = cross(b1, b2);
764                    let nn2 = cross(b2, b3);
765                    let mm1 = cross(nn1, b2);
766                    let b2l = dot(b2, b2).sqrt().max(1e-8);
767                    (-dot(mm1, nn2) / b2l).atan2(dot(nn1, nn2))
768                };
769
770                let e_p = 0.5
771                    * (self.v1 * (1.0 + phi_p.cos())
772                        + self.v2 * (1.0 - (2.0 * phi_p).cos())
773                        + self.v3 * (1.0 + (3.0 * phi_p).cos()));
774                let e_m = 0.5
775                    * (self.v1 * (1.0 + phi_m.cos())
776                        + self.v2 * (1.0 - (2.0 * phi_m).cos())
777                        + self.v3 * (1.0 + (3.0 * phi_m).cos()));
778                grad[idx] += (e_p - e_m) / (2.0 * eps);
779            }
780        }
781
782        energy
783    }
784}
785
786// ─── MMFF94 Buffered 14-7 Van der Waals ──────────────────────────────────────
787
788/// Dispersión estérica repulsiva/atractiva regida por el Potencial Amortiguado 14-7 (Buffered 14-7) de Halgren.
789pub struct Mmff94BufferedVanDerWaals {
790    pub atom_i_idx: usize,
791    pub atom_j_idx: usize,
792    pub radius_star: f64,   // Parámetro dimensional empírico cruzado R*ij
793    pub epsilon_depth: f64, // Factor de profundidad termodinámica eps_ij
794}
795
796impl ForceFieldContribution for Mmff94BufferedVanDerWaals {
797    fn evaluate_energy_and_inject_gradient(&self, coords: &[f64], grad: &mut [f64]) -> f64 {
798        let root_i = self.atom_i_idx * 3;
799        let root_j = self.atom_j_idx * 3;
800
801        let delta_x = coords[root_i] - coords[root_j];
802        let delta_y = coords[root_i + 1] - coords[root_j + 1];
803        let delta_z = coords[root_i + 2] - coords[root_j + 2];
804
805        let dist_squared = delta_x * delta_x + delta_y * delta_y + delta_z * delta_z;
806        let mut dist_r = dist_squared.sqrt();
807
808        // Tope asintótico absoluto inferior para colisiones
809        if dist_r < 1e-8 {
810            dist_r = 1e-8;
811        }
812
813        // Algebra fraccionaria amortiguadora: E_vdW = eps * (1.07 R* / (R + 0.07 R*))^7 * ((1.12 R*^7 / (R^7 + 0.12 R*^7)) - 2)
814        let r_star_powered_7 = self.radius_star.powi(7);
815        let dist_r_powered_7 = dist_r.powi(7);
816
817        let repulsive_denominator = dist_r + 0.07 * self.radius_star;
818        let repulsive_term = (1.07 * self.radius_star / repulsive_denominator).powi(7);
819
820        let attractive_denominator = dist_r_powered_7 + 0.12 * r_star_powered_7;
821        let attractive_term = (1.12 * r_star_powered_7 / attractive_denominator) - 2.0;
822
823        let vdw_total_energy = self.epsilon_depth * repulsive_term * attractive_term;
824
825        // Derivación espacial analítica
826        let gradient_rep_term = -7.0 * repulsive_term / repulsive_denominator;
827        let gradient_attr_term = -7.0 * dist_r.powi(6) * (1.12 * r_star_powered_7)
828            / (attractive_denominator * attractive_denominator);
829
830        let force_scalar_magnitude = self.epsilon_depth
831            * (gradient_rep_term * attractive_term + repulsive_term * gradient_attr_term);
832
833        // Factorización cartesiana
834        let vector_prefactor = force_scalar_magnitude / dist_r;
835        let grad_x = vector_prefactor * delta_x;
836        let grad_y = vector_prefactor * delta_y;
837        let grad_z = vector_prefactor * delta_z;
838
839        grad[root_i] += grad_x;
840        grad[root_i + 1] += grad_y;
841        grad[root_i + 2] += grad_z;
842
843        grad[root_j] -= grad_x;
844        grad[root_j + 1] -= grad_y;
845        grad[root_j + 2] -= grad_z;
846
847        vdw_total_energy
848    }
849}
850
851// ─── Gradient Validation ─────────────────────────────────────────────────────
852
853/// Validate analytical gradients against numerical (central-difference) gradients.
854/// Returns max absolute error across all coordinates.
855pub fn validate_gradients(term: &dyn ForceFieldContribution, coords: &[f64], eps: f64) -> f64 {
856    let n = coords.len();
857    let mut analytical_grad = vec![0.0; n];
858    term.evaluate_energy_and_inject_gradient(coords, &mut analytical_grad);
859
860    let mut max_err = 0.0f64;
861    for i in 0..n {
862        let mut c_plus = coords.to_vec();
863        let mut c_minus = coords.to_vec();
864        c_plus[i] += eps;
865        c_minus[i] -= eps;
866
867        let mut g_dummy = vec![0.0; n];
868        let e_plus = term.evaluate_energy_and_inject_gradient(&c_plus, &mut g_dummy);
869        g_dummy.fill(0.0);
870        let e_minus = term.evaluate_energy_and_inject_gradient(&c_minus, &mut g_dummy);
871
872        let numerical = (e_plus - e_minus) / (2.0 * eps);
873        let err = (analytical_grad[i] - numerical).abs();
874        max_err = max_err.max(err);
875    }
876    max_err
877}
878
879// ─── MMFF94 Builder (assemble terms for a molecule) ──────────────────────────
880
881/// Simple bond/angle/torsion lookup parameters for building MMFF94 terms.
882/// Uses fallback empirical rules when proper MMFF94 parameter tables are not available.
883pub struct Mmff94Builder;
884
885impl Mmff94Builder {
886    /// Estimate MMFF94 bond stretching parameters from elements and bond order.
887    fn bond_params(elem_i: u8, elem_j: u8, _bond_order: u8) -> (f64, f64) {
888        // r0 (Å) from covalent radii sum, kb from empirical rule
889        let r_cov = |e: u8| -> f64 {
890            match e {
891                1 => 0.31,
892                6 => 0.76,
893                7 => 0.71,
894                8 => 0.66,
895                9 => 0.57,
896                15 => 1.07,
897                16 => 1.05,
898                17 => 1.02,
899                35 => 1.20,
900                53 => 1.39,
901                _ => 1.0,
902            }
903        };
904        let r0 = r_cov(elem_i) + r_cov(elem_j);
905        let kb = 5.0; // Fallback; real MMFF94 uses typed parameters
906        (kb, r0)
907    }
908
909    /// Build all MMFF94 force field terms for a parsed molecule.
910    ///
911    /// `elements`: atomic numbers.
912    /// `bonds`: list of (atom_i, atom_j, bond_order).
913    /// `coords`: flat xyz coordinates.
914    ///
915    /// Returns boxed force field contributions.
916    pub fn build(
917        elements: &[u8],
918        bonds: &[(usize, usize, u8)],
919    ) -> Vec<Box<dyn ForceFieldContribution>> {
920        let n_atoms = elements.len();
921        let mut terms: Vec<Box<dyn ForceFieldContribution>> = Vec::new();
922
923        // Bond stretching terms
924        for &(i, j, order) in bonds {
925            let (kb, r0) = Self::bond_params(elements[i], elements[j], order);
926            terms.push(Box::new(Mmff94BondStretch {
927                atom_i: i,
928                atom_j: j,
929                k_b: kb,
930                r0,
931            }));
932        }
933
934        // Angle bending: find all i-j-k where (i,j) and (j,k) are bonded
935        let mut neighbors: Vec<Vec<usize>> = vec![Vec::new(); n_atoms];
936        for &(i, j, _) in bonds {
937            neighbors[i].push(j);
938            neighbors[j].push(i);
939        }
940        for j in 0..n_atoms {
941            let nbrs = &neighbors[j];
942            for a in 0..nbrs.len() {
943                for b in (a + 1)..nbrs.len() {
944                    let i = nbrs[a];
945                    let k = nbrs[b];
946                    terms.push(Box::new(Mmff94AngleBend {
947                        atom_i: i,
948                        atom_j: j,
949                        atom_k: k,
950                        k_a: 0.5,                       // Fallback force constant
951                        theta0: 109.5_f64.to_radians(), // SP3 default
952                    }));
953                }
954            }
955        }
956
957        // Torsion terms: find all i-j-k-l where (i,j), (j,k), (k,l) are bonded
958        for &(j, k, _) in bonds {
959            for &i in &neighbors[j] {
960                if i == k {
961                    continue;
962                }
963                for &l in &neighbors[k] {
964                    if l == j || l == i {
965                        continue;
966                    }
967                    terms.push(Box::new(Mmff94Torsion {
968                        atom_i: i,
969                        atom_j: j,
970                        atom_k: k,
971                        atom_l: l,
972                        v1: 0.0,
973                        v2: 1.0,
974                        v3: 0.0, // Generic 2-fold barrier
975                    }));
976                }
977            }
978        }
979
980        // 1-4 vdW terms (atoms separated by 3 bonds)
981        for &(j, k, _) in bonds {
982            for &i in &neighbors[j] {
983                if i == k {
984                    continue;
985                }
986                for &l in &neighbors[k] {
987                    if l == j || l == i {
988                        continue;
989                    }
990                    let r_star = 3.5; // Generic
991                    let eps = 0.05;
992                    terms.push(Box::new(Mmff94BufferedVanDerWaals {
993                        atom_i_idx: i,
994                        atom_j_idx: l,
995                        radius_star: r_star,
996                        epsilon_depth: eps,
997                    }));
998                }
999            }
1000        }
1001
1002        terms
1003    }
1004
1005    /// Compute total energy from all MMFF94 terms.
1006    pub fn total_energy(
1007        terms: &[Box<dyn ForceFieldContribution>],
1008        coords: &[f64],
1009    ) -> (f64, Vec<f64>) {
1010        let n = coords.len();
1011        let mut grad = vec![0.0; n];
1012        let mut total = 0.0;
1013        for term in terms {
1014            total += term.evaluate_energy_and_inject_gradient(coords, &mut grad);
1015        }
1016        (total, grad)
1017    }
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::*;
1023
1024    #[test]
1025    fn test_mmff94_vdw_energy() {
1026        let term = Mmff94BufferedVanDerWaals {
1027            atom_i_idx: 0,
1028            atom_j_idx: 1,
1029            radius_star: 3.6,
1030            epsilon_depth: 0.1,
1031        };
1032        let coords = vec![0.0, 0.0, 0.0, 3.6, 0.0, 0.0];
1033        let mut grad = vec![0.0; 6];
1034        let e = term.evaluate_energy_and_inject_gradient(&coords, &mut grad);
1035        // At equilibrium distance, energy should be near -epsilon
1036        assert!(e < 0.0 && e > -0.2, "vdW energy at R*: {e}");
1037    }
1038
1039    #[test]
1040    fn test_mmff94_vdw_gradient_validation() {
1041        let term = Mmff94BufferedVanDerWaals {
1042            atom_i_idx: 0,
1043            atom_j_idx: 1,
1044            radius_star: 3.6,
1045            epsilon_depth: 0.1,
1046        };
1047        let coords = vec![0.0, 0.0, 0.0, 4.0, 0.0, 0.0];
1048        let max_err = validate_gradients(&term, &coords, 1e-5);
1049        assert!(max_err < 1e-4, "vdW gradient error: {max_err}");
1050    }
1051
1052    #[test]
1053    fn test_mmff94_bond_stretch() {
1054        let term = Mmff94BondStretch {
1055            atom_i: 0,
1056            atom_j: 1,
1057            k_b: 5.0,
1058            r0: 1.5,
1059        };
1060        // At equilibrium: energy ≈ 0
1061        let coords_eq = vec![0.0, 0.0, 0.0, 1.5, 0.0, 0.0];
1062        let mut grad = vec![0.0; 6];
1063        let e_eq = term.evaluate_energy_and_inject_gradient(&coords_eq, &mut grad);
1064        assert!(e_eq.abs() < 1e-10, "bond stretch at r0: {e_eq}");
1065
1066        // Stretched: energy > 0
1067        let coords_stretch = vec![0.0, 0.0, 0.0, 2.0, 0.0, 0.0];
1068        grad.fill(0.0);
1069        let e_str = term.evaluate_energy_and_inject_gradient(&coords_stretch, &mut grad);
1070        assert!(
1071            e_str > 0.0,
1072            "bond stretch energy should be positive: {e_str}"
1073        );
1074    }
1075
1076    #[test]
1077    fn test_mmff94_bond_stretch_gradient_validation() {
1078        let term = Mmff94BondStretch {
1079            atom_i: 0,
1080            atom_j: 1,
1081            k_b: 5.0,
1082            r0: 1.5,
1083        };
1084        let coords = vec![0.0, 0.0, 0.0, 2.0, 0.1, 0.0];
1085        let max_err = validate_gradients(&term, &coords, 1e-5);
1086        assert!(max_err < 1e-3, "bond stretch gradient error: {max_err}");
1087    }
1088
1089    #[test]
1090    fn test_mmff94_torsion_energy() {
1091        let term = Mmff94Torsion {
1092            atom_i: 0,
1093            atom_j: 1,
1094            atom_k: 2,
1095            atom_l: 3,
1096            v1: 0.0,
1097            v2: 5.0,
1098            v3: 0.0,
1099        };
1100        // Planar trans: phi ≈ 180°
1101        let coords = vec![-1.5, 1.0, 0.0, 0.0, 0.0, 0.0, 1.5, 0.0, 0.0, 3.0, 1.0, 0.0];
1102        let mut grad = vec![0.0; 12];
1103        let e = term.evaluate_energy_and_inject_gradient(&coords, &mut grad);
1104        assert!(e.is_finite(), "torsion energy should be finite: {e}");
1105    }
1106
1107    #[test]
1108    fn test_mmff94_atom_typing() {
1109        use crate::graph::Hybridization;
1110        let t = assign_mmff94_type(6, &Hybridization::SP3, false, false);
1111        assert_eq!(t, Mmff94AtomType::CR);
1112        let t = assign_mmff94_type(6, &Hybridization::SP2, true, false);
1113        assert_eq!(t, Mmff94AtomType::CB);
1114        let t = assign_mmff94_type(7, &Hybridization::SP3, false, true);
1115        assert_eq!(t, Mmff94AtomType::NAm);
1116    }
1117
1118    #[test]
1119    fn test_mmff94_builder_ethane() {
1120        // Ethane: C-C with 6 hydrogens
1121        let elements = vec![6, 6, 1, 1, 1, 1, 1, 1]; // C, C, H×6
1122        let bonds = vec![
1123            (0, 1, 1), // C-C
1124            (0, 2, 1),
1125            (0, 3, 1),
1126            (0, 4, 1), // C-H
1127            (1, 5, 1),
1128            (1, 6, 1),
1129            (1, 7, 1), // C-H
1130        ];
1131        // Staggered ethane coordinates (approximate)
1132        let coords = vec![
1133            0.0, 0.0, 0.0, // C0
1134            1.54, 0.0, 0.0, // C1
1135            -0.5, 0.9, 0.0, // H
1136            -0.5, -0.9, 0.0, // H
1137            -0.5, 0.0, 0.9, // H
1138            2.04, 0.9, 0.0, // H
1139            2.04, -0.9, 0.0, // H
1140            2.04, 0.0, 0.9, // H
1141        ];
1142        let terms = Mmff94Builder::build(&elements, &bonds);
1143        assert!(!terms.is_empty(), "should produce force field terms");
1144
1145        let (energy, grad) = Mmff94Builder::total_energy(&terms, &coords);
1146        assert!(
1147            energy.is_finite(),
1148            "total energy should be finite: {energy}"
1149        );
1150        assert!(
1151            grad.iter().all(|g| g.is_finite()),
1152            "all gradients should be finite"
1153        );
1154    }
1155
1156    #[test]
1157    fn test_mmff94_builder_gradient_consistency() {
1158        // Verify total gradient is consistent with numerical for a simple system
1159        let elements = vec![6, 6];
1160        let bonds = vec![(0, 1, 1)];
1161        let coords = vec![0.0, 0.0, 0.0, 1.6, 0.1, 0.0];
1162        let terms = Mmff94Builder::build(&elements, &bonds);
1163        let (_, analytical_grad) = Mmff94Builder::total_energy(&terms, &coords);
1164
1165        let eps = 1e-5;
1166        for i in 0..coords.len() {
1167            let mut cp = coords.clone();
1168            let mut cm = coords.clone();
1169            cp[i] += eps;
1170            cm[i] -= eps;
1171            let (ep, _) = Mmff94Builder::total_energy(&terms, &cp);
1172            let (em, _) = Mmff94Builder::total_energy(&terms, &cm);
1173            let numerical = (ep - em) / (2.0 * eps);
1174            let err = (analytical_grad[i] - numerical).abs();
1175            assert!(
1176                err < 0.1,
1177                "gradient mismatch at coord {i}: anal={:.6} num={:.6} err={:.6}",
1178                analytical_grad[i],
1179                numerical,
1180                err
1181            );
1182        }
1183    }
1184}