Skip to main content

chematic_ff/
mmff94_minimizer.rs

1//! MMFF94 geometry minimizer using complete Halgren 1996 parameters.
2//!
3//! Provides full MMFF94 energy evaluation (bond + angle + torsion + vdW + electrostatic)
4//! and geometry optimization with two algorithms:
5//! - **Steepest descent** (`minimize_mmff94_full`) — robust, simple
6//! - **L-BFGS** (`minimize_mmff94_lbfgs`) — faster convergence, quasi-Newton
7//!
8//! ## Energy terms
9//! - **Bond**: cubic-corrected harmonic (Halgren MMFF.II eq. 1)
10//! - **Angle**: cubic-corrected harmonic (Halgren MMFF.III eq. 2)
11//! - **Torsion**: three-term Fourier (Halgren MMFF.IV)
12//! - **vdW**: buffered 14-7 potential with Slater-Kirkwood combining rule (Halgren MMFF.I eq. 2)
13//! - **Electrostatic**: Coulomb with δ buffer (Halgren MMFF.V eq. 14)
14
15use std::collections::VecDeque;
16
17use chematic_core::{AtomIdx, Molecule};
18
19use crate::mmff94_energy::{
20    mmff94_angle_energy, mmff94_bond_energy, mmff94_oop, mmff94_stbn, mmff94_torsion_energy,
21    mmff94_vdw_combined,
22};
23use crate::mmff94_numeric::{
24    NumericTypeError, assign_mmff94_numeric_types, mmff94_charges_numeric,
25};
26
27type CoordVec = Vec<[f64; 3]>;
28type LbfgsHistory = VecDeque<(CoordVec, CoordVec, f64)>;
29
30// ─── Public types ────────────────────────────────────────────────────────────
31
32/// Result of a geometry minimization run.
33#[derive(Debug, Clone)]
34pub struct MinimizeResult {
35    /// Final MMFF94 energy (kcal/mol).
36    pub energy: f64,
37    /// RMSD of atom positions vs initial geometry (Å).
38    pub rmsd: f64,
39    /// Whether the minimization converged before `max_iter`.
40    pub converged: bool,
41    /// Number of gradient steps performed.
42    pub iterations: usize,
43}
44
45/// Error from MMFF94 minimizer setup.
46#[derive(Debug)]
47pub enum MinimizerError {
48    TypeAssignment(NumericTypeError),
49}
50
51impl From<NumericTypeError> for MinimizerError {
52    fn from(e: NumericTypeError) -> Self {
53        MinimizerError::TypeAssignment(e)
54    }
55}
56
57impl std::fmt::Display for MinimizerError {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            MinimizerError::TypeAssignment(e) => write!(f, "MMFF94 type assignment failed: {}", e),
61        }
62    }
63}
64
65// ─── Public API ──────────────────────────────────────────────────────────────
66
67/// Per-term MMFF94 energy breakdown (kcal/mol). Includes all 7 Halgren 1996 energy terms.
68#[derive(Debug, Clone, Copy)]
69pub struct EnergyBreakdown {
70    pub bond: f64,
71    pub angle: f64,
72    /// Stretch-bend coupling (STRE-BEN, Halgren MMFF.V)
73    pub stretch_bend: f64,
74    pub torsion: f64,
75    /// Out-of-plane bending for sp2 atoms (Halgren MMFF.VI)
76    pub oop: f64,
77    pub vdw: f64,
78    pub electrostatic: f64,
79    pub total: f64,
80}
81
82/// Compute total MMFF94 energy for a given geometry (kcal/mol).
83///
84/// Includes bond, angle, torsion, vdW, and electrostatic terms.
85/// Does not modify coordinates.
86pub fn mmff94_total_energy(mol: &Molecule, coords: &[[f64; 3]]) -> Result<f64, MinimizerError> {
87    let types = assign_mmff94_numeric_types(mol)?;
88    let charges = mmff94_charges_numeric(mol).unwrap_or_else(|_| vec![0.0; mol.atom_count()]);
89    Ok(total_energy(mol, coords, &types, &charges))
90}
91
92/// Scan a torsion dihedral angle i-j-k-l from 0° to 360° in `steps` increments,
93/// returning (angle_deg, energy_kcal) pairs. Coordinates are not modified.
94///
95/// At each step the dihedral is set by rotating atoms past `k` about the j-k bond.
96pub fn mmff94_torsion_scan(
97    mol: &Molecule,
98    coords: &[[f64; 3]],
99    atom_i: usize,
100    atom_j: usize,
101    atom_k: usize,
102    atom_l: usize,
103    steps: usize,
104) -> Result<Vec<(f64, f64)>, MinimizerError> {
105    let types = assign_mmff94_numeric_types(mol)?;
106    let charges = mmff94_charges_numeric(mol).unwrap_or_else(|_| vec![0.0; mol.atom_count()]);
107    let n = mol.atom_count();
108    let steps = steps.max(2);
109
110    let mut results = Vec::with_capacity(steps);
111
112    // Collect atoms on the `l` side of the j-k bond (BFS from k, not crossing j)
113    let moving_atoms: Vec<usize> = {
114        let mut visited = vec![false; n];
115        visited[atom_j] = true;
116        let mut queue = std::collections::VecDeque::new();
117        queue.push_back(atom_k);
118        visited[atom_k] = true;
119        let mut group = Vec::new();
120        while let Some(cur) = queue.pop_front() {
121            group.push(cur);
122            for (nb, _) in mol.neighbors(AtomIdx(cur as u32)) {
123                let nbi = nb.0 as usize;
124                if !visited[nbi] {
125                    visited[nbi] = true;
126                    queue.push_back(nbi);
127                }
128            }
129        }
130        group
131    };
132
133    let mut work = coords.to_vec();
134
135    // Rotate the moving group in `steps` increments of 360°/steps
136    let step_rad = 2.0 * std::f64::consts::PI / steps as f64;
137
138    for step in 0..steps {
139        let angle_deg = step as f64 * 360.0 / steps as f64;
140
141        if step > 0 {
142            // Rotate moving_atoms by step_rad about the j→k axis
143            let j = work[atom_j];
144            let k = work[atom_k];
145            let axis = {
146                let d = [k[0] - j[0], k[1] - j[1], k[2] - j[2]];
147                let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
148                if len < 1e-12 {
149                    [1.0, 0.0, 0.0]
150                } else {
151                    [d[0] / len, d[1] / len, d[2] / len]
152                }
153            };
154            let (sin_a, cos_a) = step_rad.sin_cos();
155            for &ai in &moving_atoms {
156                // Rodrigues' rotation about axis through j
157                let p = [work[ai][0] - j[0], work[ai][1] - j[1], work[ai][2] - j[2]];
158                let cross = [
159                    axis[1] * p[2] - axis[2] * p[1],
160                    axis[2] * p[0] - axis[0] * p[2],
161                    axis[0] * p[1] - axis[1] * p[0],
162                ];
163                let dot = axis[0] * p[0] + axis[1] * p[1] + axis[2] * p[2];
164                work[ai] = [
165                    j[0] + cos_a * p[0] + sin_a * cross[0] + (1.0 - cos_a) * dot * axis[0],
166                    j[1] + cos_a * p[1] + sin_a * cross[1] + (1.0 - cos_a) * dot * axis[1],
167                    j[2] + cos_a * p[2] + sin_a * cross[2] + (1.0 - cos_a) * dot * axis[2],
168                ];
169                let _ = (atom_i, atom_l); // suppress unused warnings
170            }
171        }
172
173        let energy = total_energy(mol, &work, &types, &charges);
174        results.push((angle_deg, energy));
175    }
176
177    Ok(results)
178}
179
180/// Compute per-term MMFF94 energy breakdown for a given geometry.
181pub fn mmff94_energy_breakdown(
182    mol: &Molecule,
183    coords: &[[f64; 3]],
184) -> Result<EnergyBreakdown, MinimizerError> {
185    let types = assign_mmff94_numeric_types(mol)?;
186    let charges = mmff94_charges_numeric(mol).unwrap_or_else(|_| vec![0.0; mol.atom_count()]);
187    let b = bond_energy(mol, coords, &types);
188    let a = angle_energy(mol, coords, &types);
189    let sb = stretch_bend_energy(mol, coords, &types);
190    let t = torsion_energy(mol, coords, &types);
191    let o = oop_energy(mol, coords, &types);
192    let v = vdw_energy(mol, coords, &types);
193    let e = elec_energy(mol, coords, &charges);
194    Ok(EnergyBreakdown {
195        bond: b,
196        angle: a,
197        stretch_bend: sb,
198        torsion: t,
199        oop: o,
200        vdw: v,
201        electrostatic: e,
202        total: b + a + sb + t + o + v + e,
203    })
204}
205
206/// Minimize molecular geometry using the full MMFF94 force field.
207///
208/// Uses steepest descent with finite-difference gradients and the complete
209/// Halgren 1996 parameter tables (bond, angle, torsion, vdW, electrostatic).
210///
211/// # Arguments
212/// * `mol` — molecule graph (topology only, no coordinates)
213/// * `coords` — initial 3D coordinates `[[x, y, z]]` in Å; updated in place
214/// * `max_iter` — maximum gradient steps (200 typically sufficient)
215pub fn minimize_mmff94_full(
216    mol: &Molecule,
217    coords: &mut [[f64; 3]],
218    max_iter: usize,
219) -> Result<MinimizeResult, MinimizerError> {
220    if mol.atom_count() <= 1 {
221        return Ok(MinimizeResult {
222            energy: 0.0,
223            rmsd: 0.0,
224            converged: true,
225            iterations: 0,
226        });
227    }
228
229    let types = assign_mmff94_numeric_types(mol)?;
230    let charges = mmff94_charges_numeric(mol).unwrap_or_else(|_| vec![0.0; mol.atom_count()]);
231
232    let n = mol.atom_count();
233    let initial = coords.to_vec();
234    let convergence = 1e-4_f64;
235    let step_size = 0.05_f64;
236    let delta = 1e-4_f64;
237
238    let mut iters = 0usize;
239    let mut converged = false;
240
241    for _ in 0..max_iter {
242        iters += 1;
243        let grad = compute_gradient(mol, coords, &types, &charges, delta);
244        let max_g = grad
245            .iter()
246            .flat_map(|v| v.iter())
247            .map(|x| x.abs())
248            .fold(0.0_f64, f64::max);
249
250        if max_g < convergence {
251            converged = true;
252            break;
253        }
254
255        let scale = step_size / max_g.max(1e-8);
256        for i in 0..n {
257            for axis in 0..3 {
258                coords[i][axis] -= scale * grad[i][axis];
259            }
260        }
261    }
262
263    let energy = total_energy(mol, coords, &types, &charges);
264
265    let rmsd = {
266        let sum: f64 = coords
267            .iter()
268            .zip(initial.iter())
269            .map(|(c, i0)| {
270                let dx = c[0] - i0[0];
271                let dy = c[1] - i0[1];
272                let dz = c[2] - i0[2];
273                dx * dx + dy * dy + dz * dz
274            })
275            .sum();
276        (sum / n as f64).sqrt()
277    };
278
279    Ok(MinimizeResult {
280        energy,
281        rmsd,
282        converged,
283        iterations: iters,
284    })
285}
286
287/// Minimize molecular geometry using L-BFGS (limited-memory quasi-Newton).
288///
289/// Typically converges in 2–5× fewer iterations than steepest descent for
290/// well-behaved energy surfaces. Falls back to a steepest-descent step when
291/// the curvature condition `y·s > 0` is not satisfied.
292///
293/// Uses finite-difference gradients (δ=1e-4 Å) and backtracking Armijo line search.
294pub fn minimize_mmff94_lbfgs(
295    mol: &Molecule,
296    coords: &mut [[f64; 3]],
297    max_iter: usize,
298) -> Result<MinimizeResult, MinimizerError> {
299    const M: usize = 5; // L-BFGS history size
300    const DELTA: f64 = 1e-4; // finite-difference step (Å)
301    const CONVERGENCE: f64 = 1e-4; // max |gradient| threshold
302    const C_ARMIJO: f64 = 1e-4; // Armijo sufficient-decrease constant
303    const TAU: f64 = 0.5; // Armijo backtracking factor
304
305    if mol.atom_count() <= 1 {
306        return Ok(MinimizeResult {
307            energy: 0.0,
308            rmsd: 0.0,
309            converged: true,
310            iterations: 0,
311        });
312    }
313
314    let types = assign_mmff94_numeric_types(mol)?;
315    let charges = mmff94_charges_numeric(mol).unwrap_or_else(|_| vec![0.0; mol.atom_count()]);
316
317    let n = mol.atom_count();
318    let initial = coords.to_vec();
319
320    // Circular history buffer: (s_k = Δx, y_k = Δg, ρ_k = 1/(y·s))
321    let mut history: LbfgsHistory = VecDeque::new();
322
323    let mut g = compute_gradient(mol, coords, &types, &charges, DELTA);
324    let mut f0 = total_energy(mol, coords, &types, &charges);
325
326    let mut iters = 0usize;
327    let mut converged = false;
328
329    for _ in 0..max_iter {
330        iters += 1;
331
332        // Convergence check
333        let max_g = g
334            .iter()
335            .flat_map(|v| v.iter())
336            .map(|x| x.abs())
337            .fold(0.0_f64, f64::max);
338        if max_g < CONVERGENCE {
339            converged = true;
340            break;
341        }
342
343        // Two-loop L-BFGS recursion → search direction p
344        let p = lbfgs_direction(&g, &history);
345
346        // Armijo backtracking line search along p
347        let gp: f64 = g.iter().zip(p.iter()).map(|(gi, pi)| dot3(*gi, *pi)).sum();
348        let mut alpha = 1.0_f64;
349        let new_coords = loop {
350            let trial: Vec<[f64; 3]> = coords
351                .iter()
352                .zip(p.iter())
353                .map(|(c, pi)| {
354                    [
355                        c[0] + alpha * pi[0],
356                        c[1] + alpha * pi[1],
357                        c[2] + alpha * pi[2],
358                    ]
359                })
360                .collect();
361            let f_trial = total_energy(mol, &trial, &types, &charges);
362            if f_trial <= f0 + C_ARMIJO * alpha * gp {
363                break trial;
364            }
365            alpha *= TAU;
366            if alpha < 1e-12 {
367                // Line search failed — take a tiny steepest descent step
368                let scale = 0.01 / max_g.max(1e-8);
369                break coords
370                    .iter()
371                    .zip(g.iter())
372                    .map(|(c, gi)| {
373                        [
374                            c[0] - scale * gi[0],
375                            c[1] - scale * gi[1],
376                            c[2] - scale * gi[2],
377                        ]
378                    })
379                    .collect();
380            }
381        };
382
383        // Compute new gradient
384        let g_new = compute_gradient(mol, &new_coords, &types, &charges, DELTA);
385        let f_new = total_energy(mol, &new_coords, &types, &charges);
386
387        // Compute s = x_new - x, y = g_new - g
388        let s: Vec<[f64; 3]> = new_coords
389            .iter()
390            .zip(coords.iter())
391            .map(|(xn, xo)| [xn[0] - xo[0], xn[1] - xo[1], xn[2] - xo[2]])
392            .collect();
393        let y: Vec<[f64; 3]> = g_new
394            .iter()
395            .zip(g.iter())
396            .map(|(gn, go)| [gn[0] - go[0], gn[1] - go[1], gn[2] - go[2]])
397            .collect();
398        let ys: f64 = y.iter().zip(s.iter()).map(|(yi, si)| dot3(*yi, *si)).sum();
399
400        // Only store if curvature condition holds
401        if ys > 1e-10 {
402            if history.len() >= M {
403                history.pop_front();
404            }
405            history.push_back((s, y, 1.0 / ys));
406        }
407
408        coords.copy_from_slice(&new_coords);
409        g = g_new;
410        f0 = f_new;
411    }
412
413    let rmsd = {
414        let sum: f64 = coords
415            .iter()
416            .zip(initial.iter())
417            .map(|(c, i0)| {
418                let dx = c[0] - i0[0];
419                let dy = c[1] - i0[1];
420                let dz = c[2] - i0[2];
421                dx * dx + dy * dy + dz * dz
422            })
423            .sum();
424        (sum / n as f64).sqrt()
425    };
426
427    Ok(MinimizeResult {
428        energy: f0,
429        rmsd,
430        converged,
431        iterations: iters,
432    })
433}
434
435/// L-BFGS two-loop recursion: compute search direction p = -H_k × g.
436fn lbfgs_direction(g: &[[f64; 3]], history: &LbfgsHistory) -> Vec<[f64; 3]> {
437    let m = history.len();
438
439    if m == 0 {
440        // No history: steepest descent direction
441        return g.iter().map(|gi| [-gi[0], -gi[1], -gi[2]]).collect();
442    }
443
444    let mut q: Vec<[f64; 3]> = g.to_vec();
445    let mut alphas = vec![0.0_f64; m];
446
447    // First loop (backward)
448    for i in (0..m).rev() {
449        let (s, y, rho) = &history[i];
450        let sq: f64 = s.iter().zip(q.iter()).map(|(si, qi)| dot3(*si, *qi)).sum();
451        alphas[i] = rho * sq;
452        let a = alphas[i];
453        for (qi, yi) in q.iter_mut().zip(y.iter()) {
454            qi[0] -= a * yi[0];
455            qi[1] -= a * yi[1];
456            qi[2] -= a * yi[2];
457        }
458    }
459
460    // Scale by γ = (s_{m-1}·y_{m-1}) / (y_{m-1}·y_{m-1})
461    let (s_last, y_last, _) = &history[m - 1];
462    let sy: f64 = s_last
463        .iter()
464        .zip(y_last.iter())
465        .map(|(si, yi)| dot3(*si, *yi))
466        .sum();
467    let yy: f64 = y_last.iter().map(|yi| dot3(*yi, *yi)).sum();
468    let gamma = if yy > 1e-20 { sy / yy } else { 1.0 };
469    for qi in q.iter_mut() {
470        qi[0] *= gamma;
471        qi[1] *= gamma;
472        qi[2] *= gamma;
473    }
474
475    // Second loop (forward)
476    for i in 0..m {
477        let (s, y, rho) = &history[i];
478        let yr: f64 = y.iter().zip(q.iter()).map(|(yi, ri)| dot3(*yi, *ri)).sum();
479        let beta = rho * yr;
480        let diff = alphas[i] - beta;
481        for (qi, si) in q.iter_mut().zip(s.iter()) {
482            qi[0] += diff * si[0];
483            qi[1] += diff * si[1];
484            qi[2] += diff * si[2];
485        }
486    }
487
488    // p = -H_k g = -q
489    q.iter().map(|qi| [-qi[0], -qi[1], -qi[2]]).collect()
490}
491
492/// Compute finite-difference gradient: ∂E/∂x_i via central differences.
493fn compute_gradient(
494    mol: &Molecule,
495    coords: &[[f64; 3]],
496    types: &[u8],
497    charges: &[f64],
498    delta: f64,
499) -> Vec<[f64; 3]> {
500    let n = coords.len();
501    let mut grad = vec![[0.0_f64; 3]; n];
502    let mut work = coords.to_vec();
503    for i in 0..n {
504        for axis in 0..3 {
505            work[i][axis] += delta;
506            let ep = total_energy(mol, &work, types, charges);
507            work[i][axis] -= 2.0 * delta;
508            let em = total_energy(mol, &work, types, charges);
509            work[i][axis] += delta;
510            grad[i][axis] = (ep - em) / (2.0 * delta);
511        }
512    }
513    grad
514}
515
516// ─── Energy components ───────────────────────────────────────────────────────
517
518fn total_energy(mol: &Molecule, coords: &[[f64; 3]], types: &[u8], charges: &[f64]) -> f64 {
519    bond_energy(mol, coords, types)
520        + angle_energy(mol, coords, types)
521        + stretch_bend_energy(mol, coords, types)
522        + torsion_energy(mol, coords, types)
523        + oop_energy(mol, coords, types)
524        + vdw_energy(mol, coords, types)
525        + elec_energy(mol, coords, charges)
526}
527
528/// Stretch-bend coupling (Halgren MMFF.V eq. 4)
529/// E_sb = 2.51210 × (kba_ijk × Δr_ij + kba_kji × Δr_kj) × Δθ   [kcal/mol, Δθ in degrees]
530fn stretch_bend_energy(mol: &Molecule, coords: &[[f64; 3]], types: &[u8]) -> f64 {
531    const CONV: f64 = 2.51210; // md/Å → kcal/(mol·Å·deg)
532    const RAD_TO_DEG: f64 = 180.0 / std::f64::consts::PI;
533    const KB_CONV: f64 = 143.9325;
534    const CS: f64 = 2.0;
535    let mut energy = 0.0;
536    for j_idx in 0..mol.atom_count() {
537        let j = AtomIdx(j_idx as u32);
538        let neighbors: Vec<usize> = mol.neighbors(j).map(|(nb, _)| nb.0 as usize).collect();
539        if neighbors.len() < 2 {
540            continue;
541        }
542        let at = angle_type_for(types[j_idx]);
543        for (ii, &i) in neighbors.iter().enumerate() {
544            for &k in &neighbors[ii + 1..] {
545                if let Some((kba_ijk, kba_kji)) = mmff94_stbn(at, types[i], types[j_idx], types[k])
546                {
547                    // Δr_ij
548                    let r_ij = dist(coords[i], coords[j_idx]);
549                    let bt_ij = bond_type_for(types[i], types[j_idx]);
550                    let dr_ij = if let Some(p) = mmff94_bond_energy(bt_ij, types[i], types[j_idx]) {
551                        r_ij - p.r0
552                    } else {
553                        0.0
554                    };
555                    // Δr_kj
556                    let r_kj = dist(coords[k], coords[j_idx]);
557                    let bt_kj = bond_type_for(types[k], types[j_idx]);
558                    let dr_kj = if let Some(p) = mmff94_bond_energy(bt_kj, types[k], types[j_idx]) {
559                        r_kj - p.r0
560                    } else {
561                        0.0
562                    };
563                    // Δθ in degrees
564                    let cos_t = cos_angle(coords[i], coords[j_idx], coords[k]);
565                    if let Some(ap) = mmff94_angle_energy(at, types[i], types[j_idx], types[k]) {
566                        let dtheta = cos_t.acos() * RAD_TO_DEG - ap.theta0;
567                        energy += CONV * (kba_ijk * dr_ij + kba_kji * dr_kj) * dtheta;
568                    }
569                    let _ = (KB_CONV, CS); // suppress warnings
570                }
571            }
572        }
573    }
574    energy
575}
576
577/// Out-of-plane bending for trigonal sp2 centers (Halgren MMFF.VI eq. 6)
578/// E_oop = (0.043844 × koop / 2) × χ²  (χ in degrees: Wilson angle of out-of-plane distortion)
579fn oop_energy(mol: &Molecule, coords: &[[f64; 3]], types: &[u8]) -> f64 {
580    const CONV: f64 = 0.043844;
581    const RAD_TO_DEG: f64 = 180.0 / std::f64::consts::PI;
582    // sp2 atom types that can have OOP bending
583    const SP2_TYPES: &[u8] = &[
584        2, 3, 9, 10, 30, 37, 38, 39, 40, 41, 43, 45, 49, 54, 56, 57, 58, 59, 63, 64, 65, 66, 67,
585        76, 78, 79, 80, 81, 82,
586    ];
587    let mut energy = 0.0;
588    for j_idx in 0..mol.atom_count() {
589        if SP2_TYPES.binary_search(&types[j_idx]).is_err() {
590            continue;
591        }
592        let j = AtomIdx(j_idx as u32);
593        let neighbors: Vec<usize> = mol.neighbors(j).map(|(nb, _)| nb.0 as usize).collect();
594        if neighbors.len() != 3 {
595            continue; // OOP only for exactly 3 substituents (trigonal)
596        }
597        let [i, k, l] = [neighbors[0], neighbors[1], neighbors[2]];
598        if let Some(koop) = mmff94_oop(types[j_idx], types[i], types[k], types[l]) {
599            // Wilson out-of-plane angle: angle between j→l vector and plane (i,j,k)
600            let pj = coords[j_idx];
601            let pi = coords[i];
602            let pk = coords[k];
603            let pl = coords[l];
604            let rji = [pi[0] - pj[0], pi[1] - pj[1], pi[2] - pj[2]];
605            let rjk = [pk[0] - pj[0], pk[1] - pj[1], pk[2] - pj[2]];
606            let rjl = [pl[0] - pj[0], pl[1] - pj[1], pl[2] - pj[2]];
607            let n = cross(rji, rjk); // normal to ijk plane
608            let n_len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
609            let l_len = (rjl[0] * rjl[0] + rjl[1] * rjl[1] + rjl[2] * rjl[2]).sqrt();
610            if n_len < 1e-12 || l_len < 1e-12 {
611                continue;
612            }
613            let sin_chi = dot3(n, rjl) / (n_len * l_len);
614            let chi_deg = sin_chi.clamp(-1.0, 1.0).asin() * RAD_TO_DEG;
615            energy += (CONV * koop / 2.0) * chi_deg * chi_deg;
616        }
617    }
618    energy
619}
620
621/// Bond stretching: cubic-corrected harmonic (Halgren MMFF.II eq. 1)
622/// E = (143.9325 × kb / 2) × ΔR² × (1 − cs×ΔR + (7/12)×cs²×ΔR²)
623fn bond_energy(mol: &Molecule, coords: &[[f64; 3]], types: &[u8]) -> f64 {
624    const KB_CONV: f64 = 143.9325;
625    const CS: f64 = 2.0;
626    let mut energy = 0.0;
627    for (_, bond) in mol.bonds() {
628        let i = bond.atom1.0 as usize;
629        let j = bond.atom2.0 as usize;
630        let bt = bond_type_for(types[i], types[j]);
631        if let Some(p) = mmff94_bond_energy(bt, types[i], types[j]) {
632            let r = dist(coords[i], coords[j]);
633            let dr = r - p.r0;
634            let cubic = 1.0 - CS * dr + (7.0 / 12.0) * CS * CS * dr * dr;
635            energy += (KB_CONV * p.kb / 2.0) * dr * dr * cubic;
636        }
637    }
638    energy
639}
640
641/// Angle bending: cubic-corrected harmonic (Halgren MMFF.III eq. 2)
642/// E = (0.043844 × ka / 2) × Δθ² × (1 − 0.007×Δθ)   [Δθ in degrees]
643fn angle_energy(mol: &Molecule, coords: &[[f64; 3]], types: &[u8]) -> f64 {
644    const KA_CONV: f64 = 0.043844;
645    const RAD_TO_DEG: f64 = 180.0 / std::f64::consts::PI;
646    let mut energy = 0.0;
647    for j_idx in 0..mol.atom_count() {
648        let j = AtomIdx(j_idx as u32);
649        let neighbors: Vec<usize> = mol.neighbors(j).map(|(nb, _)| nb.0 as usize).collect();
650        if neighbors.len() < 2 {
651            continue;
652        }
653        let at = angle_type_for(types[j_idx]);
654        for (ii, &i) in neighbors.iter().enumerate() {
655            for &k in &neighbors[ii + 1..] {
656                if let Some(p) = mmff94_angle_energy(at, types[i], types[j_idx], types[k]) {
657                    let cos_t = cos_angle(coords[i], coords[j_idx], coords[k]);
658                    let theta_deg = cos_t.acos() * RAD_TO_DEG;
659                    let dt = theta_deg - p.theta0;
660                    let cubic = 1.0 - 0.007 * dt;
661                    energy += (KA_CONV * p.ka / 2.0) * dt * dt * cubic;
662                }
663            }
664        }
665    }
666    energy
667}
668
669/// Torsion: three-term Fourier (Halgren MMFF.IV)
670/// E = (v1/2)(1+cosφ) + (v2/2)(1-cos2φ) + (v3/2)(1+cos3φ)
671fn torsion_energy(mol: &Molecule, coords: &[[f64; 3]], types: &[u8]) -> f64 {
672    let mut energy = 0.0;
673    for (_, bond) in mol.bonds() {
674        let j = bond.atom1.0 as usize;
675        let k = bond.atom2.0 as usize;
676        let nbrs_j: Vec<usize> = mol
677            .neighbors(bond.atom1)
678            .map(|(nb, _)| nb.0 as usize)
679            .collect();
680        let nbrs_k: Vec<usize> = mol
681            .neighbors(bond.atom2)
682            .map(|(nb, _)| nb.0 as usize)
683            .collect();
684        let tt = torsion_type_for(types[j], types[k]);
685        for &i in &nbrs_j {
686            if i == k {
687                continue;
688            }
689            for &l in &nbrs_k {
690                if l == j {
691                    continue;
692                }
693                if let Some(p) = mmff94_torsion_energy(tt, types[i], types[j], types[k], types[l]) {
694                    let phi = dihedral(coords[i], coords[j], coords[k], coords[l]);
695                    energy += 0.5 * p.v1 * (1.0 + phi.cos())
696                        + 0.5 * p.v2 * (1.0 - (2.0 * phi).cos())
697                        + 0.5 * p.v3 * (1.0 + (3.0 * phi).cos());
698                }
699            }
700        }
701    }
702    energy
703}
704
705/// Van der Waals: buffered 14-7 (Halgren MMFF.I eq. 2)
706/// t = (1.07 × r*) / (r + 0.07 × r*)
707/// E = ε × t⁷ × (t⁷ − 2)
708fn vdw_energy(mol: &Molecule, coords: &[[f64; 3]], types: &[u8]) -> f64 {
709    let n = mol.atom_count();
710    let mut excl = std::collections::HashSet::new();
711    for (_, bond) in mol.bonds() {
712        let i = bond.atom1.0 as usize;
713        let j = bond.atom2.0 as usize;
714        excl.insert((i.min(j), i.max(j)));
715        for (nb_i, _) in mol.neighbors(bond.atom1) {
716            let ni = nb_i.0 as usize;
717            excl.insert((ni.min(j), ni.max(j)));
718        }
719        for (nb_j, _) in mol.neighbors(bond.atom2) {
720            let nj = nb_j.0 as usize;
721            excl.insert((i.min(nj), i.max(nj)));
722        }
723    }
724    let cutoff = 10.0_f64;
725    let mut energy = 0.0;
726    for i in 0..n {
727        for j in (i + 1)..n {
728            if excl.contains(&(i, j)) {
729                continue;
730            }
731            let r = dist(coords[i], coords[j]);
732            if r > cutoff {
733                continue;
734            }
735            if let Some((r_star, eps)) = mmff94_vdw_combined(types[i], types[j])
736                && r_star > 0.0
737                && eps > 0.0
738                && r > 0.01
739            {
740                let t = (1.07 * r_star) / (r + 0.07 * r_star);
741                let t7 = t.powi(7);
742                energy += eps * t7 * (t7 - 2.0);
743            }
744        }
745    }
746    energy
747}
748
749/// Electrostatic: Coulomb with δ=0.05 Å buffer (Halgren MMFF.V eq. 14)
750/// E = 332.0716 × q_i × q_j / (D × (r + δ))   [D=1.0]
751fn elec_energy(mol: &Molecule, coords: &[[f64; 3]], charges: &[f64]) -> f64 {
752    const COULOMB: f64 = 332.0716;
753    const DELTA: f64 = 0.05;
754    let n = mol.atom_count();
755    let mut excl = std::collections::HashSet::new();
756    for (_, bond) in mol.bonds() {
757        let i = bond.atom1.0 as usize;
758        let j = bond.atom2.0 as usize;
759        excl.insert((i.min(j), i.max(j)));
760        for (nb_i, _) in mol.neighbors(bond.atom1) {
761            excl.insert(((nb_i.0 as usize).min(j), (nb_i.0 as usize).max(j)));
762        }
763        for (nb_j, _) in mol.neighbors(bond.atom2) {
764            excl.insert((i.min(nb_j.0 as usize), i.max(nb_j.0 as usize)));
765        }
766    }
767    // 1-4 pairs: scale by 0.75 (MMFF94 convention)
768    let mut one_four = std::collections::HashSet::new();
769    for (_, bond) in mol.bonds() {
770        let j = bond.atom1.0 as usize;
771        let k = bond.atom2.0 as usize;
772        for (nb_j, _) in mol.neighbors(bond.atom1) {
773            let i = nb_j.0 as usize;
774            if i == k {
775                continue;
776            }
777            for (nb_k, _) in mol.neighbors(bond.atom2) {
778                let l = nb_k.0 as usize;
779                if l == j {
780                    continue;
781                }
782                let key = (i.min(l), i.max(l));
783                if !excl.contains(&key) {
784                    one_four.insert(key);
785                }
786            }
787        }
788    }
789    let mut energy = 0.0;
790    for i in 0..n {
791        for j in (i + 1)..n {
792            if excl.contains(&(i, j)) {
793                continue;
794            }
795            let r = dist(coords[i], coords[j]);
796            let scale = if one_four.contains(&(i, j)) {
797                0.75
798            } else {
799                1.0
800            };
801            energy += scale * COULOMB * charges[i] * charges[j] / (r + DELTA);
802        }
803    }
804    energy
805}
806
807// ─── Geometry helpers ─────────────────────────────────────────────────────────
808
809#[inline]
810fn dist(a: [f64; 3], b: [f64; 3]) -> f64 {
811    let dx = a[0] - b[0];
812    let dy = a[1] - b[1];
813    let dz = a[2] - b[2];
814    (dx * dx + dy * dy + dz * dz).sqrt()
815}
816
817#[inline]
818fn cos_angle(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> f64 {
819    let ba = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
820    let bc = [c[0] - b[0], c[1] - b[1], c[2] - b[2]];
821    let dot_val = ba[0] * bc[0] + ba[1] * bc[1] + ba[2] * bc[2];
822    let na = (ba[0] * ba[0] + ba[1] * ba[1] + ba[2] * ba[2]).sqrt();
823    let nc = (bc[0] * bc[0] + bc[1] * bc[1] + bc[2] * bc[2]).sqrt();
824    if na < 1e-12 || nc < 1e-12 {
825        return 0.0;
826    }
827    (dot_val / (na * nc)).clamp(-1.0, 1.0)
828}
829
830/// Signed dihedral angle φ (radians) for the quartet i-j-k-l.
831#[inline]
832fn dihedral(i: [f64; 3], j: [f64; 3], k: [f64; 3], l: [f64; 3]) -> f64 {
833    let b1 = [j[0] - i[0], j[1] - i[1], j[2] - i[2]];
834    let b2 = [k[0] - j[0], k[1] - j[1], k[2] - j[2]];
835    let b3 = [l[0] - k[0], l[1] - k[1], l[2] - k[2]];
836    let n1 = cross(b1, b2);
837    let n2 = cross(b2, b3);
838    let m1 = cross(n1, b2);
839    let b2_len = (b2[0] * b2[0] + b2[1] * b2[1] + b2[2] * b2[2]).sqrt();
840    if b2_len < 1e-12 {
841        return 0.0;
842    }
843    let x = dot3(n1, n2);
844    let y = dot3(m1, n2) / b2_len;
845    y.atan2(x)
846}
847
848#[inline]
849fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
850    [
851        a[1] * b[2] - a[2] * b[1],
852        a[2] * b[0] - a[0] * b[2],
853        a[0] * b[1] - a[1] * b[0],
854    ]
855}
856
857#[inline]
858fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
859    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
860}
861
862// ─── Type classification helpers ──────────────────────────────────────────────
863
864/// Determine MMFF94 bond type: 1 if either atom is sp2/aromatic, else 0.
865fn bond_type_for(ti: u8, tj: u8) -> u8 {
866    const SP2: &[u8] = &[
867        2, 3, 9, 10, 37, 38, 39, 40, 41, 56, 57, 58, 59, 63, 64, 65, 66, 67, 78, 79, 80, 81, 82,
868    ];
869    if SP2.binary_search(&ti).is_ok() || SP2.binary_search(&tj).is_ok() {
870        1
871    } else {
872        0
873    }
874}
875
876/// Determine MMFF94 angle type (simplified: 0 for most organic angles).
877fn angle_type_for(_tj: u8) -> u8 {
878    0
879}
880
881/// Determine MMFF94 torsion type from central bond atom types.
882fn torsion_type_for(tj: u8, tk: u8) -> u8 {
883    const SP2: &[u8] = &[
884        2, 3, 9, 10, 37, 38, 39, 40, 41, 56, 57, 58, 59, 63, 64, 65, 66, 67, 78, 79, 80, 81, 82,
885    ];
886    match (
887        SP2.binary_search(&tj).is_ok(),
888        SP2.binary_search(&tk).is_ok(),
889    ) {
890        (false, false) => 0,                // sp3-sp3
891        (true, false) | (false, true) => 1, // sp3-sp2
892        (true, true) => 2,                  // sp2-sp2
893    }
894}
895
896// ─── Tests ───────────────────────────────────────────────────────────────────
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901    use chematic_core::molecule::MoleculeBuilder;
902    use chematic_core::{Atom, BondOrder, Element};
903
904    fn methane_mol() -> (Molecule, Vec<[f64; 3]>) {
905        let mut b = MoleculeBuilder::new();
906        let c = b.add_atom(Atom::new(Element::C));
907        let h1 = b.add_atom(Atom::new(Element::H));
908        let h2 = b.add_atom(Atom::new(Element::H));
909        let h3 = b.add_atom(Atom::new(Element::H));
910        let h4 = b.add_atom(Atom::new(Element::H));
911        b.add_bond(c, h1, BondOrder::Single).unwrap();
912        b.add_bond(c, h2, BondOrder::Single).unwrap();
913        b.add_bond(c, h3, BondOrder::Single).unwrap();
914        b.add_bond(c, h4, BondOrder::Single).unwrap();
915        let mol = b.build();
916        let coords = vec![
917            [0.0, 0.0, 0.0],
918            [0.630, 0.630, 0.630],
919            [-0.630, -0.630, 0.630],
920            [-0.630, 0.630, -0.630],
921            [0.630, -0.630, -0.630],
922        ];
923        (mol, coords)
924    }
925
926    fn butane_backbone() -> Molecule {
927        let mut b = MoleculeBuilder::new();
928        let c0 = b.add_atom(Atom::new(Element::C));
929        let c1 = b.add_atom(Atom::new(Element::C));
930        let c2 = b.add_atom(Atom::new(Element::C));
931        let c3 = b.add_atom(Atom::new(Element::C));
932        b.add_bond(c0, c1, BondOrder::Single).unwrap();
933        b.add_bond(c1, c2, BondOrder::Single).unwrap();
934        b.add_bond(c2, c3, BondOrder::Single).unwrap();
935        b.build()
936    }
937
938    #[test]
939    fn energy_is_finite_for_methane() {
940        let (mol, coords) = methane_mol();
941        let e = mmff94_total_energy(&mol, &coords).expect("energy");
942        assert!(e.is_finite(), "energy={}", e);
943    }
944
945    #[test]
946    fn torsion_differs_by_conformation() {
947        let mol = butane_backbone();
948        let types = assign_mmff94_numeric_types(&mol).expect("types");
949        // Gauche: ~60° central dihedral
950        let coords_gauche = vec![
951            [0.0, 0.0, 0.0_f64],
952            [1.508, 0.0, 0.0],
953            [2.016, 1.192, 0.688],
954            [3.524, 1.192, 0.688],
955        ];
956        // Anti: ~180° central dihedral
957        let coords_anti = vec![
958            [0.0, 0.0, 0.0_f64],
959            [1.508, 0.0, 0.0],
960            [3.016, 0.0, 0.0],
961            [4.524, 0.0, 0.0],
962        ];
963        let e_gauche = torsion_energy(&mol, &coords_gauche, &types);
964        let e_anti = torsion_energy(&mol, &coords_anti, &types);
965        assert!(e_gauche.is_finite());
966        assert!(e_anti.is_finite());
967        assert!(
968            (e_gauche - e_anti).abs() > 1e-6,
969            "torsion must differ: gauche={}, anti={}",
970            e_gauche,
971            e_anti
972        );
973    }
974
975    #[test]
976    fn vdw_more_repulsive_at_short_range() {
977        let mol = butane_backbone();
978        let types = assign_mmff94_numeric_types(&mol).expect("types");
979        // Atoms 0 and 3 are 1-4 (not excluded from vdW)
980        let coords_close = vec![
981            [0.0, 0.0, 0.0_f64],
982            [1.5, 0.0, 0.0],
983            [3.0, 0.0, 0.0],
984            [0.5, 0.0, 0.0], // atom 3 very close to atom 0
985        ];
986        let coords_far = vec![
987            [0.0, 0.0, 0.0_f64],
988            [1.5, 0.0, 0.0],
989            [3.0, 0.0, 0.0],
990            [8.0, 0.0, 0.0],
991        ];
992        let e_close = vdw_energy(&mol, &coords_close, &types);
993        let e_far = vdw_energy(&mol, &coords_far, &types);
994        assert!(e_close.is_finite());
995        assert!(e_far.is_finite());
996        assert!(e_close > e_far, "close={} should > far={}", e_close, e_far);
997    }
998
999    #[test]
1000    fn dihedral_anti_is_pi() {
1001        let i = [0.0_f64, 0.0, 0.0];
1002        let j = [1.0, 0.0, 0.0];
1003        let k = [2.0, 0.0, 1.0];
1004        let l = [3.0, 0.0, 0.0];
1005        let phi = dihedral(i, j, k, l);
1006        assert!(phi.abs() > 2.5, "anti dihedral ≈ π: {}", phi);
1007    }
1008
1009    #[test]
1010    fn dihedral_syn_is_zero() {
1011        let i = [0.0_f64, 1.0, 0.0];
1012        let j = [0.0, 0.0, 0.0];
1013        let k = [1.0, 0.0, 0.0];
1014        let l = [1.0, 1.0, 0.0];
1015        let phi = dihedral(i, j, k, l);
1016        assert!(phi.abs() < 0.1, "syn dihedral ≈ 0: {}", phi);
1017    }
1018
1019    #[test]
1020    fn minimize_reduces_energy_for_methane() {
1021        let (mol, _) = methane_mol();
1022        let mut coords = vec![
1023            [0.0, 0.0, 0.0_f64],
1024            [1.5, 1.5, 1.5],
1025            [-1.5, -1.5, 1.5],
1026            [-1.5, 1.5, -1.5],
1027            [1.5, -1.5, -1.5],
1028        ];
1029        let e_before = mmff94_total_energy(&mol, &coords).expect("energy before");
1030        let result = minimize_mmff94_full(&mol, &mut coords, 300).expect("minimize");
1031        assert!(
1032            result.energy <= e_before,
1033            "minimize should reduce energy: {} → {}",
1034            e_before,
1035            result.energy
1036        );
1037        assert!(result.energy.is_finite());
1038        assert!(result.iterations > 0);
1039    }
1040
1041    #[test]
1042    fn lbfgs_reduces_energy_for_methane() {
1043        let (mol, _) = methane_mol();
1044        let mut coords = vec![
1045            [0.0, 0.0, 0.0_f64],
1046            [1.5, 1.5, 1.5],
1047            [-1.5, -1.5, 1.5],
1048            [-1.5, 1.5, -1.5],
1049            [1.5, -1.5, -1.5],
1050        ];
1051        let e_before = mmff94_total_energy(&mol, &coords).expect("energy before");
1052        let result = minimize_mmff94_lbfgs(&mol, &mut coords, 300).expect("lbfgs");
1053        assert!(
1054            result.energy <= e_before,
1055            "L-BFGS should reduce energy: {} → {}",
1056            e_before,
1057            result.energy
1058        );
1059        assert!(result.energy.is_finite());
1060    }
1061
1062    #[test]
1063    fn lbfgs_converges_in_fewer_iters_than_sd() {
1064        let (mol, _) = methane_mol();
1065        // Moderately distorted — both should converge but L-BFGS faster
1066        let base_coords = vec![
1067            [0.0, 0.0, 0.0_f64],
1068            [1.2, 1.2, 1.2],
1069            [-1.2, -1.2, 1.2],
1070            [-1.2, 1.2, -1.2],
1071            [1.2, -1.2, -1.2],
1072        ];
1073        let mut coords_sd = base_coords.clone();
1074        let mut coords_lbfgs = base_coords;
1075        let sd = minimize_mmff94_full(&mol, &mut coords_sd, 500).expect("sd");
1076        let lb = minimize_mmff94_lbfgs(&mol, &mut coords_lbfgs, 500).expect("lbfgs");
1077        // Both should converge; L-BFGS should need ≤ SD iterations
1078        assert!(
1079            lb.iterations <= sd.iterations || lb.converged,
1080            "L-BFGS iters={} SD iters={}",
1081            lb.iterations,
1082            sd.iterations
1083        );
1084        assert!(lb.energy.is_finite());
1085    }
1086
1087    #[test]
1088    fn energy_breakdown_sums_to_total() {
1089        let (mol, coords) = methane_mol();
1090        let bd = mmff94_energy_breakdown(&mol, &coords).expect("breakdown");
1091        let sum =
1092            bd.bond + bd.angle + bd.stretch_bend + bd.torsion + bd.oop + bd.vdw + bd.electrostatic;
1093        assert!(
1094            (sum - bd.total).abs() < 1e-10,
1095            "sum={} total={}",
1096            sum,
1097            bd.total
1098        );
1099        assert!(bd.total.is_finite());
1100    }
1101
1102    #[test]
1103    fn energy_breakdown_bond_term_positive_for_distorted() {
1104        let (mol, _) = methane_mol();
1105        // Very distorted C-H bonds → high bond energy
1106        let stretched = vec![
1107            [0.0, 0.0, 0.0_f64],
1108            [2.0, 2.0, 2.0],
1109            [-2.0, -2.0, 2.0],
1110            [-2.0, 2.0, -2.0],
1111            [2.0, -2.0, -2.0],
1112        ];
1113        let bd = mmff94_energy_breakdown(&mol, &stretched).expect("breakdown");
1114        assert!(
1115            bd.bond > 0.0,
1116            "stretched bond energy should be positive: {}",
1117            bd.bond
1118        );
1119    }
1120}