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