Skip to main content

chematic_3d/
minimize.rs

1//! Simplified force-field geometry minimization for molecular structures.
2//!
3//! Uses gradient descent with finite differences over energy terms:
4//! bond stretching, angle bending, VDW repulsion, and (for MMFF94) electrostatic interactions.
5//! Bond lengths and angles use element-specific parameters; charges use 3D geometry.
6
7use std::collections::HashSet;
8
9use chematic_core::{AtomIdx, BondOrder, Molecule};
10use chematic_ff::{
11    assign_dreiding_types, assign_mmff94_types, dreiding_angle, dreiding_bond_len, dreiding_vdw,
12    mmff94_angle_params, mmff94_bond_params, mmff94_charges_3d, mmff94_vdw_params,
13};
14
15use crate::coords::{Coords3D, Point3};
16
17// ---------------------------------------------------------------------------
18// Force field parameters
19// ---------------------------------------------------------------------------
20
21/// Bond stretching spring constant (kcal/mol/Ų).
22/// Used in both DREIDING and generic force fields.
23const BOND_SPRING_CONSTANT: f64 = 700.0;
24
25/// Angle bending spring constant (kcal/mol/rad²).
26/// Used in both DREIDING and generic force fields.
27const ANGLE_SPRING_CONSTANT: f64 = 100.0;
28
29/// Van der Waals interaction cutoff distance (Ångströms).
30/// Interactions beyond this distance are ignored.
31const VDW_CUTOFF: f64 = 8.0;
32
33// ---------------------------------------------------------------------------
34// Public API
35// ---------------------------------------------------------------------------
36
37/// Force field selection for minimization.
38#[derive(Debug, Clone, Copy, Default)]
39pub enum ForceField {
40    /// UFF-derived force field (default, fast).
41    UFF,
42    /// DREIDING force field.
43    #[default]
44    DREIDING,
45    /// MMFF94 force field (Merck Molecular Force Field 94, industry standard).
46    MMFF94,
47}
48
49/// Configuration for the minimization algorithm.
50pub struct MinimizeConfig {
51    /// Maximum number of gradient-descent steps.
52    pub max_steps: usize,
53    /// Base step size for coordinate updates (scaled by max gradient).
54    pub step_size: f64,
55    /// Convergence threshold: stop when max gradient component < this value.
56    pub convergence: f64,
57    /// Force field to use for energy calculation.
58    pub force_field: ForceField,
59}
60
61impl Default for MinimizeConfig {
62    fn default() -> Self {
63        Self {
64            max_steps: 200,
65            step_size: 0.05,
66            convergence: 1e-4,
67            force_field: ForceField::DREIDING,
68        }
69    }
70}
71
72/// Minimize molecular geometry using default configuration.
73pub fn minimize(mol: &Molecule, coords: Coords3D) -> Coords3D {
74    minimize_with_config(mol, coords, &MinimizeConfig::default())
75}
76
77/// Alias for [`minimize`] using UFF-derived energy terms.
78///
79/// Provided for discoverability; identical to calling `minimize(mol, coords)`.
80pub fn minimize_uff(mol: &Molecule, coords: Coords3D) -> Coords3D {
81    minimize(mol, coords)
82}
83
84/// Minimize molecular geometry using DREIDING force field parameters.
85///
86/// Uses the same gradient descent approach as [`minimize`], but employs DREIDING
87/// force field parameters for bond lengths, angles, and VDW interactions instead of UFF.
88///
89/// # Arguments
90/// * `mol` - Molecule to minimize
91/// * `coords` - Initial 3D coordinates
92///
93/// # Returns
94/// Minimized coordinates
95pub fn minimize_dreiding(mol: &Molecule, coords: Coords3D) -> Coords3D {
96    minimize_dreiding_with_config(mol, coords, &MinimizeConfig::default())
97}
98
99/// Minimize molecular geometry using MMFF94 force field (industry standard for small molecules).
100///
101/// MMFF94 (Merck Molecular Force Field 94) provides high-quality geometry optimization
102/// suitable for drug-like molecules. This is the recommended force field for most use cases.
103///
104/// # Arguments
105/// * `mol` - Molecule to minimize
106/// * `coords` - Initial 3D coordinates
107///
108/// # Returns
109/// Minimized coordinates
110pub fn minimize_mmff94(mol: &Molecule, coords: Coords3D) -> Coords3D {
111    let config = MinimizeConfig {
112        force_field: ForceField::MMFF94,
113        ..MinimizeConfig::default()
114    };
115    minimize_with_config(mol, coords, &config)
116}
117
118/// Generic gradient descent minimization with custom energy evaluation function.
119///
120/// This function encapsulates the core gradient descent loop, accepting a closure
121/// that computes the energy given current coordinates. This allows different force fields
122/// to share the same optimization control flow.
123///
124/// # Arguments
125/// * `mol` - Molecule to minimize
126/// * `coords` - Initial 3D coordinates
127/// * `config` - Minimization configuration
128/// * `energy_fn` - Closure that computes total energy for given coordinates
129fn minimize_gradient_descent<F>(
130    mol: &Molecule,
131    coords: Coords3D,
132    config: &MinimizeConfig,
133    energy_fn: F,
134) -> Coords3D
135where
136    F: Fn(&Coords3D) -> f64,
137{
138    if mol.atom_count() <= 1 {
139        return coords;
140    }
141
142    let mut c = coords;
143    let delta = 1e-4;
144
145    for _ in 0..config.max_steps {
146        let mut grad = vec![Point3::zero(); mol.atom_count()];
147        let mut max_grad = 0.0f64;
148
149        for i in 0..mol.atom_count() {
150            let idx = AtomIdx(i as u32);
151
152            // Compute gradient components via finite differences along x, y, z
153            grad[i].x = {
154                let orig = c.get(idx);
155                let mut p = orig;
156                p.x += delta;
157                c.set(idx, p);
158                let ep = energy_fn(&c);
159                let mut p = orig;
160                p.x -= delta;
161                c.set(idx, p);
162                let em = energy_fn(&c);
163                c.set(idx, orig);
164                (ep - em) / (2.0 * delta)
165            };
166
167            grad[i].y = {
168                let orig = c.get(idx);
169                let mut p = orig;
170                p.y += delta;
171                c.set(idx, p);
172                let ep = energy_fn(&c);
173                let mut p = orig;
174                p.y -= delta;
175                c.set(idx, p);
176                let em = energy_fn(&c);
177                c.set(idx, orig);
178                (ep - em) / (2.0 * delta)
179            };
180
181            grad[i].z = {
182                let orig = c.get(idx);
183                let mut p = orig;
184                p.z += delta;
185                c.set(idx, p);
186                let ep = energy_fn(&c);
187                let mut p = orig;
188                p.z -= delta;
189                c.set(idx, p);
190                let em = energy_fn(&c);
191                c.set(idx, orig);
192                (ep - em) / (2.0 * delta)
193            };
194
195            let gmax = grad[i].x.abs().max(grad[i].y.abs()).max(grad[i].z.abs());
196            if gmax > max_grad {
197                max_grad = gmax;
198            }
199        }
200
201        if max_grad < config.convergence {
202            break;
203        }
204
205        let scale = config.step_size / max_grad.max(1e-8);
206        for i in 0..mol.atom_count() {
207            let idx = AtomIdx(i as u32);
208            let p = c.get(idx);
209            c.set(
210                idx,
211                Point3::new(
212                    p.x - scale * grad[i].x,
213                    p.y - scale * grad[i].y,
214                    p.z - scale * grad[i].z,
215                ),
216            );
217        }
218    }
219
220    c
221}
222
223/// Internal MMFF94 minimization implementation with custom config.
224fn minimize_mmff94_with_config(
225    mol: &Molecule,
226    coords: Coords3D,
227    config: &MinimizeConfig,
228) -> Coords3D {
229    if mol.atom_count() <= 1 {
230        return coords;
231    }
232
233    // Assign MMFF94 types for all atoms
234    let mmff94_types = match assign_mmff94_types(mol) {
235        Ok(types) => types,
236        Err(_) => return coords, // Fall back if type assignment fails
237    };
238
239    minimize_gradient_descent(mol, coords, config, |c| {
240        total_energy_mmff94(mol, c, &mmff94_types)
241    })
242}
243
244/// Minimize molecular geometry using DREIDING parameters with custom configuration.
245pub fn minimize_dreiding_with_config(
246    mol: &Molecule,
247    coords: Coords3D,
248    config: &MinimizeConfig,
249) -> Coords3D {
250    if mol.atom_count() <= 1 {
251        return coords;
252    }
253
254    // Assign DREIDING types for all atoms
255    let dreiding_types = assign_dreiding_types(mol);
256
257    minimize_gradient_descent(mol, coords, config, |c| {
258        total_energy_dreiding(mol, c, &dreiding_types)
259    })
260}
261
262fn total_energy_dreiding(
263    mol: &Molecule,
264    coords: &Coords3D,
265    dreiding_types: &[chematic_ff::DREIDINGType],
266) -> f64 {
267    bond_energy_dreiding(mol, coords, dreiding_types)
268        + angle_energy_dreiding(mol, coords, dreiding_types)
269        + vdw_energy_dreiding(mol, coords, dreiding_types)
270}
271
272fn bond_energy_dreiding(
273    mol: &Molecule,
274    coords: &Coords3D,
275    dreiding_types: &[chematic_ff::DREIDINGType],
276) -> f64 {
277    let mut energy = 0.0;
278    let k = BOND_SPRING_CONSTANT;
279    for (_, bond) in mol.bonds() {
280        let a1 = bond.atom1;
281        let a2 = bond.atom2;
282        let r = coords.get(a1).distance(&coords.get(a2));
283        let t1 = dreiding_types[a1.0 as usize];
284        let t2 = dreiding_types[a2.0 as usize];
285        let r0 = dreiding_bond_len(t1, t2, bond.order);
286        let dr = r - r0;
287        energy += 0.5 * k * dr * dr;
288    }
289    energy
290}
291
292fn angle_energy_dreiding(
293    mol: &Molecule,
294    coords: &Coords3D,
295    dreiding_types: &[chematic_ff::DREIDINGType],
296) -> f64 {
297    let mut energy = 0.0;
298    let k = ANGLE_SPRING_CONSTANT;
299
300    for b_idx in 0..mol.atom_count() {
301        let b = AtomIdx(b_idx as u32);
302        let neighbors: Vec<AtomIdx> = mol.neighbors(b).map(|(nb, _)| nb).collect();
303
304        if neighbors.len() < 2 {
305            continue;
306        }
307
308        let theta0 = dreiding_angle(dreiding_types[b_idx]);
309
310        for (i, &a) in neighbors.iter().enumerate() {
311            for &c in &neighbors[i + 1..] {
312                let pb = coords.get(b);
313
314                let pa = coords.get(a);
315                let pc = coords.get(c);
316
317                let va = pa.sub(&pb);
318                let vc = pc.sub(&pb);
319
320                let na = va.norm();
321                let nc = vc.norm();
322
323                if na < 1e-10 || nc < 1e-10 {
324                    continue;
325                }
326
327                let cos_theta = (va.dot(&vc) / (na * nc)).clamp(-1.0, 1.0);
328                let theta = cos_theta.acos();
329                let dtheta = theta - theta0;
330                energy += 0.5 * k * dtheta * dtheta;
331            }
332        }
333    }
334
335    energy
336}
337
338fn vdw_energy_dreiding(
339    mol: &Molecule,
340    coords: &Coords3D,
341    dreiding_types: &[chematic_ff::DREIDINGType],
342) -> f64 {
343    let n = mol.atom_count();
344    let cutoff = VDW_CUTOFF;
345
346    let mut excluded: HashSet<(usize, usize)> = HashSet::new();
347
348    for (_, bond) in mol.bonds() {
349        let i = bond.atom1.0 as usize;
350        let j = bond.atom2.0 as usize;
351        excluded.insert((i.min(j), i.max(j)));
352    }
353
354    for b_idx in 0..n {
355        let b = AtomIdx(b_idx as u32);
356        let neighbors: Vec<usize> = mol.neighbors(b).map(|(nb, _)| nb.0 as usize).collect();
357        for ii in 0..neighbors.len() {
358            for jj in (ii + 1)..neighbors.len() {
359                let i = neighbors[ii];
360                let j = neighbors[jj];
361                excluded.insert((i.min(j), i.max(j)));
362            }
363        }
364    }
365
366    let mut energy = 0.0;
367    for i in 0..n {
368        for j in (i + 1)..n {
369            if excluded.contains(&(i, j)) {
370                continue;
371            }
372            let r = coords
373                .get(AtomIdx(i as u32))
374                .distance(&coords.get(AtomIdx(j as u32)));
375
376            if r < 0.01 || r >= cutoff {
377                continue;
378            }
379
380            let t_i = dreiding_types[i];
381            let t_j = dreiding_types[j];
382            let (r0_i, well_i) = dreiding_vdw(t_i);
383            let (r0_j, well_j) = dreiding_vdw(t_j);
384
385            // Lorentz-Berthelot combining rules
386            let r0 = (r0_i + r0_j) / 2.0;
387            let well = (well_i * well_j).sqrt();
388
389            let ratio = r0 / r;
390            let ratio6 = ratio * ratio * ratio * ratio * ratio * ratio;
391            let ratio12 = ratio6 * ratio6;
392            energy += well * (ratio12 - 2.0 * ratio6);
393        }
394    }
395
396    energy
397}
398
399/// Minimize molecular geometry using the provided configuration.
400pub fn minimize_with_config(mol: &Molecule, coords: Coords3D, config: &MinimizeConfig) -> Coords3D {
401    if mol.atom_count() <= 1 {
402        return coords;
403    }
404
405    // Dispatch to appropriate force field implementation
406    match config.force_field {
407        ForceField::MMFF94 => minimize_mmff94_with_config(mol, coords, config),
408        _ => {
409            // Default UFF/DREIDING path (unchanged behavior)
410            minimize_generic_with_config(mol, coords, config)
411        }
412    }
413}
414
415fn minimize_generic_with_config(
416    mol: &Molecule,
417    coords: Coords3D,
418    config: &MinimizeConfig,
419) -> Coords3D {
420    minimize_gradient_descent(mol, coords, config, |c| total_energy(mol, c))
421}
422
423// ---------------------------------------------------------------------------
424// Total energy
425// ---------------------------------------------------------------------------
426
427fn total_energy(mol: &Molecule, coords: &Coords3D) -> f64 {
428    bond_energy(mol, coords) + angle_energy(mol, coords) + vdw_energy(mol, coords)
429}
430
431// ---------------------------------------------------------------------------
432// UFF-derived element parameters
433// ---------------------------------------------------------------------------
434
435/// Ideal bond length (Å) by atom element pair and bond order.
436/// Canonical pair: (a, b) where a <= b lexicographically.
437fn ideal_bond_len(sym1: &str, sym2: &str, order: BondOrder) -> f64 {
438    let (a, b) = if sym1 <= sym2 {
439        (sym1, sym2)
440    } else {
441        (sym2, sym1)
442    };
443    match (a, b, order) {
444        // C–C
445        ("C", "C", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.540,
446        ("C", "C", BondOrder::Double) => 1.340,
447        ("C", "C", BondOrder::Triple) => 1.204,
448        ("C", "C", BondOrder::Aromatic) => 1.395,
449        // C–H
450        ("C", "H", _) => 1.090,
451        // C–N
452        ("C", "N", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.469,
453        ("C", "N", BondOrder::Double) => 1.279,
454        ("C", "N", BondOrder::Triple) => 1.158,
455        ("C", "N", BondOrder::Aromatic) => 1.340,
456        // C–O
457        ("C", "O", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.427,
458        ("C", "O", BondOrder::Double) => 1.217,
459        ("C", "O", BondOrder::Aromatic) => 1.355,
460        // C–S
461        ("C", "S", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.819,
462        ("C", "S", BondOrder::Double) => 1.610,
463        ("C", "S", BondOrder::Aromatic) => 1.750,
464        // C–F
465        ("C", "F", _) => 1.350,
466        // C–Cl ("C" < "Cl" since "C" == "C" and "" < "l")
467        ("C", "Cl", _) => 1.770,
468        // C–Br ("Br" < "C")
469        ("Br", "C", _) => 1.940,
470        // C–I
471        ("C", "I", _) => 2.140,
472        // C–P
473        ("C", "P", _) => 1.840,
474        // C–Si
475        ("C", "Si", _) => 1.870,
476        // H–H
477        ("H", "H", _) => 0.741,
478        // H–N
479        ("H", "N", _) => 1.010,
480        // H–O
481        ("H", "O", _) => 0.960,
482        // H–S
483        ("H", "S", _) => 1.340,
484        // H–P
485        ("H", "P", _) => 1.420,
486        // N–N
487        ("N", "N", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.450,
488        ("N", "N", BondOrder::Double) => 1.250,
489        ("N", "N", BondOrder::Triple) => 1.100,
490        ("N", "N", BondOrder::Aromatic) => 1.350,
491        // N–O
492        ("N", "O", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.400,
493        ("N", "O", BondOrder::Double) => 1.210,
494        ("N", "O", BondOrder::Aromatic) => 1.340,
495        // O–O
496        ("O", "O", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.480,
497        ("O", "O", BondOrder::Double) => 1.210,
498        // S–S
499        ("S", "S", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 2.050,
500        ("S", "S", BondOrder::Double) => 1.890,
501        // P–P
502        ("P", "P", _) => 2.280,
503        // fallback: order-based only
504        _ => match order {
505            BondOrder::Single | BondOrder::Up | BondOrder::Down => 1.54,
506            BondOrder::Double => 1.34,
507            BondOrder::Triple => 1.20,
508            BondOrder::Quadruple => 1.20,
509            BondOrder::Aromatic => 1.40,
510            BondOrder::Zero
511            | BondOrder::Dative
512            | BondOrder::QueryAny
513            | BondOrder::QuerySingleOrDouble
514            | BondOrder::QuerySingleOrAromatic => 1.54,
515            BondOrder::QueryDoubleOrAromatic => 1.40,
516        },
517    }
518}
519
520/// Atom hybridization inferred from bond orders and aromaticity.
521#[derive(Clone, Copy, PartialEq, Debug)]
522enum Hybridization {
523    SP,  // linear (triple bond present)
524    SP2, // trigonal planar (double bond or aromatic)
525    SP3, // tetrahedral
526}
527
528fn atom_hybridization(mol: &Molecule, idx: AtomIdx) -> Hybridization {
529    if mol.atom(idx).aromatic {
530        return Hybridization::SP2;
531    }
532    let mut has_triple = false;
533    let mut has_double_or_aromatic = false;
534    for (_, bond_idx) in mol.neighbors(idx) {
535        match mol.bond(bond_idx).order {
536            BondOrder::Triple => has_triple = true,
537            BondOrder::Double | BondOrder::Aromatic => has_double_or_aromatic = true,
538            _ => {}
539        }
540    }
541    if has_triple {
542        Hybridization::SP
543    } else if has_double_or_aromatic {
544        Hybridization::SP2
545    } else {
546        Hybridization::SP3
547    }
548}
549
550/// Ideal bond angle (radians) for a center atom given its hybridization.
551fn ideal_angle_rad(sym: &str, hyb: Hybridization) -> f64 {
552    match hyb {
553        Hybridization::SP => 180.0_f64.to_radians(),
554        Hybridization::SP2 => 120.0_f64.to_radians(),
555        Hybridization::SP3 => match sym {
556            "O" | "Se" => 104.5_f64.to_radians(),
557            "N" => 107.0_f64.to_radians(),
558            "S" => 99.0_f64.to_radians(),
559            "P" => 93.0_f64.to_radians(),
560            _ => 109.47_f64.to_radians(),
561        },
562    }
563}
564
565/// VDW radius (Å) derived from UFF/Bondi values.
566fn uff_vdw_radius(sym: &str) -> f64 {
567    match sym {
568        "H" => 1.20,
569        "C" => 1.70,
570        "N" => 1.55,
571        "O" => 1.52,
572        "F" => 1.47,
573        "Si" => 2.10,
574        "P" => 1.80,
575        "S" => 1.80,
576        "Cl" => 1.75,
577        "Br" => 1.85,
578        "I" => 1.98,
579        "Se" => 1.90,
580        "Te" => 2.06,
581        _ => 1.70,
582    }
583}
584
585// ---------------------------------------------------------------------------
586// Bond stretching energy
587// ---------------------------------------------------------------------------
588
589fn bond_energy(mol: &Molecule, coords: &Coords3D) -> f64 {
590    let mut energy = 0.0;
591    for (_, bond) in mol.bonds() {
592        let a1 = bond.atom1;
593        let a2 = bond.atom2;
594        let r = coords.get(a1).distance(&coords.get(a2));
595        let sym1 = mol.atom(a1).element.symbol();
596        let sym2 = mol.atom(a2).element.symbol();
597        let r0 = ideal_bond_len(sym1, sym2, bond.order);
598        let dr = r - r0;
599        energy += 0.5 * BOND_SPRING_CONSTANT * dr * dr;
600    }
601    energy
602}
603
604// ---------------------------------------------------------------------------
605// Angle bending energy
606// ---------------------------------------------------------------------------
607
608fn angle_energy(mol: &Molecule, coords: &Coords3D) -> f64 {
609    let mut energy = 0.0;
610
611    for b_idx in 0..mol.atom_count() {
612        let b = AtomIdx(b_idx as u32);
613        let neighbors: Vec<AtomIdx> = mol.neighbors(b).map(|(nb, _)| nb).collect();
614
615        if neighbors.len() < 2 {
616            continue;
617        }
618
619        let sym_b = mol.atom(b).element.symbol();
620        let hyb = atom_hybridization(mol, b);
621        let theta0 = ideal_angle_rad(sym_b, hyb);
622        let pb = coords.get(b);
623
624        for i in 0..neighbors.len() {
625            for j in (i + 1)..neighbors.len() {
626                let a = neighbors[i];
627                let c = neighbors[j];
628
629                let pa = coords.get(a);
630                let pc = coords.get(c);
631
632                let va = pa.sub(&pb);
633                let vc = pc.sub(&pb);
634
635                let na = va.norm();
636                let nc = vc.norm();
637
638                if na < 1e-10 || nc < 1e-10 {
639                    continue;
640                }
641
642                let cos_theta = (va.dot(&vc) / (na * nc)).clamp(-1.0, 1.0);
643                let theta = cos_theta.acos();
644                let dtheta = theta - theta0;
645                energy += 0.5 * ANGLE_SPRING_CONSTANT * dtheta * dtheta;
646            }
647        }
648    }
649
650    energy
651}
652
653// ---------------------------------------------------------------------------
654// VDW repulsion energy
655// ---------------------------------------------------------------------------
656
657fn vdw_energy(mol: &Molecule, coords: &Coords3D) -> f64 {
658    let n = mol.atom_count();
659    let cutoff = VDW_CUTOFF;
660
661    let mut excluded: HashSet<(usize, usize)> = HashSet::new();
662
663    for (_, bond) in mol.bonds() {
664        let i = bond.atom1.0 as usize;
665        let j = bond.atom2.0 as usize;
666        excluded.insert((i.min(j), i.max(j)));
667    }
668
669    for b_idx in 0..n {
670        let b = AtomIdx(b_idx as u32);
671        let neighbors: Vec<usize> = mol.neighbors(b).map(|(nb, _)| nb.0 as usize).collect();
672        for ii in 0..neighbors.len() {
673            for jj in (ii + 1)..neighbors.len() {
674                let i = neighbors[ii];
675                let j = neighbors[jj];
676                excluded.insert((i.min(j), i.max(j)));
677            }
678        }
679    }
680
681    let mut energy = 0.0;
682    for i in 0..n {
683        for j in (i + 1)..n {
684            if excluded.contains(&(i, j)) {
685                continue;
686            }
687            let r = coords
688                .get(AtomIdx(i as u32))
689                .distance(&coords.get(AtomIdx(j as u32)));
690
691            if r < 0.01 || r >= cutoff {
692                continue;
693            }
694
695            let sym_i = mol.atom(AtomIdx(i as u32)).element.symbol();
696            let sym_j = mol.atom(AtomIdx(j as u32)).element.symbol();
697            let r0 = uff_vdw_radius(sym_i) + uff_vdw_radius(sym_j);
698
699            let ratio = r0 / r;
700            let ratio6 = ratio * ratio * ratio * ratio * ratio * ratio;
701            let ratio12 = ratio6 * ratio6;
702            energy += 0.05 * ratio12;
703        }
704    }
705
706    energy
707}
708
709// ---------------------------------------------------------------------------
710// MMFF94 Energy Calculations
711// ---------------------------------------------------------------------------
712
713fn total_energy_mmff94(
714    mol: &Molecule,
715    coords: &Coords3D,
716    mmff94_types: &[chematic_ff::MMFF94Type],
717) -> f64 {
718    let bond_e = bond_energy_mmff94(mol, coords, mmff94_types);
719    let angle_e = angle_energy_mmff94(mol, coords, mmff94_types);
720    let vdw_e = vdw_energy_mmff94(mol, coords, mmff94_types);
721
722    // Add electrostatic energy using 3D-based charges (B5 Phase 2)
723    let elec_e = electrostatic_energy_mmff94(mol, coords, mmff94_types).unwrap_or(0.0);
724
725    bond_e + angle_e + vdw_e + elec_e
726}
727
728fn bond_energy_mmff94(
729    mol: &Molecule,
730    coords: &Coords3D,
731    mmff94_types: &[chematic_ff::MMFF94Type],
732) -> f64 {
733    let mut energy = 0.0;
734
735    for (_, bond) in mol.bonds() {
736        let a1 = bond.atom1;
737        let a2 = bond.atom2;
738        let r = coords.get(a1).distance(&coords.get(a2));
739        let t1 = mmff94_types[a1.0 as usize];
740        let t2 = mmff94_types[a2.0 as usize];
741
742        if let Some(params) = mmff94_bond_params(t1, t2, bond.order) {
743            let dr = r - params.r0;
744            energy += 0.5 * params.kb * dr * dr;
745        }
746    }
747
748    energy
749}
750
751fn angle_energy_mmff94(
752    mol: &Molecule,
753    coords: &Coords3D,
754    mmff94_types: &[chematic_ff::MMFF94Type],
755) -> f64 {
756    let mut energy = 0.0;
757
758    for b_idx in 0..mol.atom_count() {
759        let b = AtomIdx(b_idx as u32);
760        let neighbors: Vec<AtomIdx> = mol.neighbors(b).map(|(nb, _)| nb).collect();
761
762        if neighbors.len() < 2 {
763            continue;
764        }
765
766        for (i, &a) in neighbors.iter().enumerate() {
767            for &c in &neighbors[i + 1..] {
768                let t1 = mmff94_types[a.0 as usize];
769                let t2 = mmff94_types[b_idx];
770                let t3 = mmff94_types[c.0 as usize];
771
772                if let Some(params) = mmff94_angle_params(t1, t2, t3) {
773                    let pb = coords.get(b);
774                    let pa = coords.get(a);
775                    let pc = coords.get(c);
776
777                    let va = pa.sub(&pb);
778                    let vc = pc.sub(&pb);
779
780                    let na = va.norm();
781                    let nc = vc.norm();
782
783                    if na < 1e-10 || nc < 1e-10 {
784                        continue;
785                    }
786
787                    let cos_theta = (va.dot(&vc) / (na * nc)).clamp(-1.0, 1.0);
788                    let theta = cos_theta.acos();
789                    let dtheta = theta - params.theta0;
790                    energy += 0.5 * params.ka * dtheta * dtheta;
791                }
792            }
793        }
794    }
795
796    energy
797}
798
799fn vdw_energy_mmff94(
800    mol: &Molecule,
801    coords: &Coords3D,
802    mmff94_types: &[chematic_ff::MMFF94Type],
803) -> f64 {
804    let n = mol.atom_count();
805    let cutoff = VDW_CUTOFF;
806    let mut excluded: HashSet<(usize, usize)> = HashSet::new();
807
808    for (_, bond) in mol.bonds() {
809        let i = bond.atom1.0 as usize;
810        let j = bond.atom2.0 as usize;
811        excluded.insert((i.min(j), i.max(j)));
812    }
813
814    // Add 1-3 exclusions (skip vdW for atoms separated by one bond)
815    for b_idx in 0..n {
816        let b = AtomIdx(b_idx as u32);
817        let neighbors: Vec<usize> = mol.neighbors(b).map(|(nb, _)| nb.0 as usize).collect();
818        for &neighbor in &neighbors {
819            excluded.insert((b_idx.min(neighbor), b_idx.max(neighbor)));
820        }
821    }
822
823    let mut energy = 0.0;
824
825    for i in 0..n {
826        for j in (i + 1)..n {
827            if excluded.contains(&(i, j)) {
828                continue;
829            }
830
831            let ri = coords.get(AtomIdx(i as u32));
832            let rj = coords.get(AtomIdx(j as u32));
833            let d = ri.distance(&rj);
834
835            if d > cutoff {
836                continue;
837            }
838
839            let params_i = mmff94_vdw_params(mmff94_types[i]);
840            let params_j = mmff94_vdw_params(mmff94_types[j]);
841
842            // Combine using geometric mean
843            let r_ij = (params_i.r_star * params_j.r_star).sqrt();
844            let eps_ij = (params_i.epsilon * params_j.epsilon).sqrt();
845
846            // Lennard-Jones 12-6
847            if d > 0.0 {
848                let r6 = (r_ij / d).powi(6);
849                energy += eps_ij * (r6 * r6 - 2.0 * r6);
850            }
851        }
852    }
853
854    energy
855}
856
857/// Electrostatic energy using Coulomb's law with 3D-based MMFF94 charges.
858/// Uses dielectric screening (kr where k~4 for organic molecules in their own environment).
859fn electrostatic_energy_mmff94(
860    mol: &Molecule,
861    coords: &Coords3D,
862    _mmff94_types: &[chematic_ff::MMFF94Type],
863) -> Result<f64, String> {
864    // Convert coordinates to tuple format for charge calculation
865    let coord_tuples: Vec<(f64, f64, f64)> = (0..mol.atom_count())
866        .map(|i| {
867            let p = coords.get(AtomIdx(i as u32));
868            (p.x, p.y, p.z)
869        })
870        .collect();
871
872    // Calculate 3D-based MMFF94 charges
873    let charges = mmff94_charges_3d(mol, &coord_tuples)
874        .map_err(|e| format!("charge calculation failed: {}", e))?;
875
876    let n = mol.atom_count();
877    let mut energy = 0.0;
878
879    // Coulomb interactions (excluding 1-2 and 1-3 pairs which are handled by bonds/angles)
880    let mut excluded: HashSet<(usize, usize)> = HashSet::new();
881
882    // Exclude 1-2 bonded pairs
883    for (_, bond) in mol.bonds() {
884        let i = bond.atom1.0 as usize;
885        let j = bond.atom2.0 as usize;
886        excluded.insert((i.min(j), i.max(j)));
887    }
888
889    // Exclude 1-3 pairs (through one bond)
890    for b_idx in 0..n {
891        let b = AtomIdx(b_idx as u32);
892        let neighbors: Vec<usize> = mol.neighbors(b).map(|(nb, _)| nb.0 as usize).collect();
893        for &neighbor in &neighbors {
894            excluded.insert((b_idx.min(neighbor), b_idx.max(neighbor)));
895        }
896    }
897
898    let dielectric = 4.0; // Screening factor for organic molecules
899    let coulomb_const = 332.0; // kcal·Ų/(mol·e²) in Ångströms
900
901    for i in 0..n {
902        for j in (i + 1)..n {
903            // Skip bonded and 1-3 interactions (handled by bonds/angles)
904            if excluded.contains(&(i, j)) {
905                continue;
906            }
907
908            let ri = coords.get(AtomIdx(i as u32));
909            let rj = coords.get(AtomIdx(j as u32));
910            let d = ri.distance(&rj);
911
912            if d > 0.01 {
913                // Coulomb interaction: E = k * q_i * q_j / (d * dielectric)
914                let coulomb = coulomb_const * charges[i] * charges[j] / (d * dielectric);
915                energy += coulomb;
916            }
917        }
918    }
919
920    Ok(energy)
921}
922
923// ---------------------------------------------------------------------------
924// Tests
925// ---------------------------------------------------------------------------
926
927#[cfg(test)]
928mod tests {
929    use super::*;
930    use crate::dg::generate_coords;
931    use chematic_smiles::parse;
932
933    fn all_pairs_min_dist(coords: &Coords3D, n: usize) -> f64 {
934        let mut min_d = f64::MAX;
935        for i in 0..n {
936            for j in (i + 1)..n {
937                let d = coords
938                    .get(AtomIdx(i as u32))
939                    .distance(&coords.get(AtomIdx(j as u32)));
940                min_d = min_d.min(d);
941            }
942        }
943        min_d
944    }
945
946    #[test]
947    fn test_single_atom_unchanged() {
948        let mol = parse("O").unwrap();
949        let coords = generate_coords(&mol);
950        let orig = coords.get(AtomIdx(0));
951        let result = minimize(&mol, coords);
952        let after = result.get(AtomIdx(0));
953        assert!((orig.x - after.x).abs() < 1e-10);
954    }
955
956    #[test]
957    fn test_zero_steps_unchanged() {
958        let mol = parse("CC").unwrap();
959        let coords = generate_coords(&mol);
960        let config = MinimizeConfig {
961            max_steps: 0,
962            ..MinimizeConfig::default()
963        };
964        let before0 = coords.get(AtomIdx(0));
965        let result = minimize_with_config(&mol, coords, &config);
966        let after0 = result.get(AtomIdx(0));
967        assert!((before0.x - after0.x).abs() < 1e-10);
968    }
969
970    #[test]
971    fn test_ethane_bond_after_minimize() {
972        let mol = parse("CC").unwrap();
973        let coords = generate_coords(&mol);
974        let result = minimize(&mol, coords);
975        let d = result.get(AtomIdx(0)).distance(&result.get(AtomIdx(1)));
976        assert!(
977            d > 1.2 && d < 1.8,
978            "C-C distance={d:.3}, expected 1.2-1.8 Å"
979        );
980    }
981
982    #[test]
983    fn test_ethane_converges_to_uff_length() {
984        let mol = parse("CC").unwrap();
985        let coords = generate_coords(&mol);
986        let result = minimize(&mol, coords);
987        let d = result.get(AtomIdx(0)).distance(&result.get(AtomIdx(1)));
988        // UFF C-C single bond is 1.540 Å; minimizer should get within 0.05 Å.
989        assert!(
990            (d - 1.540).abs() < 0.05,
991            "C-C distance={d:.4}, expected ~1.540"
992        );
993    }
994
995    #[test]
996    fn test_propane_no_clash() {
997        let mol = parse("CCC").unwrap();
998        let coords = generate_coords(&mol);
999        let result = minimize(&mol, coords);
1000        let min_d = all_pairs_min_dist(&result, mol.atom_count());
1001        assert!(min_d > 0.8, "atom clash: min distance={min_d:.3}");
1002    }
1003
1004    #[test]
1005    fn test_benzene_no_clash() {
1006        let mol = parse("c1ccccc1").unwrap();
1007        let coords = generate_coords(&mol);
1008        let result = minimize(&mol, coords);
1009        let min_d = all_pairs_min_dist(&result, mol.atom_count());
1010        assert!(
1011            min_d > 0.8,
1012            "atom clash in benzene: min distance={min_d:.3}"
1013        );
1014    }
1015
1016    #[test]
1017    fn test_disconnected_no_clash() {
1018        let mol = parse("CC.CC").unwrap();
1019        let coords = generate_coords(&mol);
1020        let result = minimize(&mol, coords);
1021        let min_d = all_pairs_min_dist(&result, mol.atom_count());
1022        assert!(
1023            min_d > 0.8,
1024            "atom clash in disconnected: min distance={min_d:.3}"
1025        );
1026    }
1027
1028    #[test]
1029    fn test_default_config_no_panic() {
1030        let mol = parse("CC(=O)O").unwrap();
1031        let coords = generate_coords(&mol);
1032        let result = minimize(&mol, coords);
1033        assert_eq!(result.atom_count(), mol.atom_count());
1034    }
1035
1036    #[test]
1037    fn test_acetic_acid_no_clash() {
1038        let mol = parse("CC(=O)O").unwrap();
1039        let coords = generate_coords(&mol);
1040        let result = minimize(&mol, coords);
1041        let min_d = all_pairs_min_dist(&result, mol.atom_count());
1042        assert!(min_d > 0.8, "clash in acetic acid: {min_d:.3}");
1043    }
1044
1045    #[test]
1046    fn test_minimize_idempotent() {
1047        let mol = parse("CCC").unwrap();
1048        let coords = generate_coords(&mol);
1049        let result1 = minimize(&mol, coords);
1050        let e1 = total_energy(&mol, &result1);
1051        let result2 = minimize(&mol, result1);
1052        let e2 = total_energy(&mol, &result2);
1053        assert!(e2 <= e1 + 1.0, "energy increased: e1={e1:.4}, e2={e2:.4}");
1054    }
1055
1056    #[test]
1057    fn test_naphthalene_no_overlap() {
1058        let mol = parse("c1ccc2ccccc2c1").unwrap();
1059        let coords = generate_coords(&mol);
1060        let result = minimize(&mol, coords);
1061        let min_d = all_pairs_min_dist(&result, mol.atom_count());
1062        assert!(min_d > 0.8, "overlap in naphthalene: {min_d:.3}");
1063    }
1064
1065    #[test]
1066    fn test_co_bond_double_shorter_than_single() {
1067        // Acetic acid: C=O should be shorter than C-O
1068        let mol = parse("CC(=O)O").unwrap();
1069        let coords = generate_coords(&mol);
1070        let result = minimize(&mol, coords);
1071        // Atom 1 is the carbonyl C, its bonds include C=O (double) and C-O (single).
1072        // Just check overall: minimized coords have no clash and atom count preserved.
1073        assert_eq!(result.atom_count(), 4);
1074        let min_d = all_pairs_min_dist(&result, 4);
1075        assert!(min_d > 0.5, "clash in CO test: {min_d:.3}");
1076    }
1077
1078    #[test]
1079    fn test_heteroatom_c_n_bond() {
1080        let mol = parse("CN").unwrap(); // methylamine
1081        let coords = generate_coords(&mol);
1082        let result = minimize(&mol, coords);
1083        let d = result.get(AtomIdx(0)).distance(&result.get(AtomIdx(1)));
1084        // C-N single bond UFF: 1.469 Å; expect within 0.1 Å.
1085        assert!(
1086            (d - 1.469).abs() < 0.1,
1087            "C-N distance={d:.4}, expected ~1.469"
1088        );
1089    }
1090
1091    #[test]
1092    fn test_acetylene_sp_hybridization() {
1093        let mol = parse("C#C").unwrap(); // acetylene: C≡C
1094        let coords = generate_coords(&mol);
1095        let result = minimize(&mol, coords);
1096        let d = result.get(AtomIdx(0)).distance(&result.get(AtomIdx(1)));
1097        // C≡C triple bond UFF: 1.204 Å; expect within 0.05 Å.
1098        assert!(
1099            (d - 1.204).abs() < 0.05,
1100            "C≡C distance={d:.4}, expected ~1.204"
1101        );
1102    }
1103
1104    #[test]
1105    fn test_ideal_bond_len_cc_single() {
1106        assert!((ideal_bond_len("C", "C", BondOrder::Single) - 1.540).abs() < 1e-6);
1107        assert!((ideal_bond_len("C", "C", BondOrder::Double) - 1.340).abs() < 1e-6);
1108        assert!((ideal_bond_len("C", "C", BondOrder::Triple) - 1.204).abs() < 1e-6);
1109        assert!((ideal_bond_len("C", "C", BondOrder::Aromatic) - 1.395).abs() < 1e-6);
1110    }
1111
1112    #[test]
1113    fn test_ideal_bond_len_symmetry() {
1114        // Should be the same regardless of argument order.
1115        let bo = BondOrder::Single;
1116        assert_eq!(ideal_bond_len("C", "N", bo), ideal_bond_len("N", "C", bo));
1117        assert_eq!(ideal_bond_len("C", "O", bo), ideal_bond_len("O", "C", bo));
1118        assert_eq!(ideal_bond_len("Br", "C", bo), ideal_bond_len("C", "Br", bo));
1119    }
1120
1121    #[test]
1122    fn test_atom_hybridization_sp2_aromatic() {
1123        let mol = parse("c1ccccc1").unwrap();
1124        for i in 0..6 {
1125            assert_eq!(
1126                atom_hybridization(&mol, AtomIdx(i)),
1127                Hybridization::SP2,
1128                "benzene atom {i} should be SP2"
1129            );
1130        }
1131    }
1132
1133    #[test]
1134    fn test_atom_hybridization_sp_triple() {
1135        let mol = parse("C#C").unwrap();
1136        assert_eq!(atom_hybridization(&mol, AtomIdx(0)), Hybridization::SP);
1137        assert_eq!(atom_hybridization(&mol, AtomIdx(1)), Hybridization::SP);
1138    }
1139
1140    #[test]
1141    fn test_atom_hybridization_sp3_alkane() {
1142        let mol = parse("CCC").unwrap();
1143        for i in 0..3 {
1144            assert_eq!(
1145                atom_hybridization(&mol, AtomIdx(i)),
1146                Hybridization::SP3,
1147                "propane atom {i} should be SP3"
1148            );
1149        }
1150    }
1151
1152    #[test]
1153    fn test_minimize_dreiding_ethane_no_clash() {
1154        let mol = parse("CC").unwrap();
1155        let coords = generate_coords(&mol);
1156        let min_coords = minimize_dreiding(&mol, coords);
1157        let n = mol.atom_count();
1158        for i in 0..n {
1159            for j in (i + 1)..n {
1160                let d = min_coords
1161                    .get(AtomIdx(i as u32))
1162                    .distance(&min_coords.get(AtomIdx(j as u32)));
1163                assert!(
1164                    d > 0.5,
1165                    "atoms {i} and {j} clashed after DREIDING minimization (d={d:.3})"
1166                );
1167            }
1168        }
1169    }
1170
1171    #[test]
1172    fn test_minimize_dreiding_benzene_no_clash() {
1173        let mol = parse("c1ccccc1").unwrap();
1174        let coords = generate_coords(&mol);
1175        let min_coords = minimize_dreiding(&mol, coords);
1176        let n = mol.atom_count();
1177        for i in 0..n {
1178            for j in (i + 1)..n {
1179                let d = min_coords
1180                    .get(AtomIdx(i as u32))
1181                    .distance(&min_coords.get(AtomIdx(j as u32)));
1182                assert!(
1183                    d > 0.5,
1184                    "atoms {i} and {j} clashed after DREIDING minimization (d={d:.3})"
1185                );
1186            }
1187        }
1188    }
1189
1190    #[test]
1191    fn test_minimize_mmff94_ethane() {
1192        let mol = parse("CC").unwrap();
1193        let c = generate_coords(&mol);
1194        let result = minimize_mmff94(&mol, c);
1195        assert_eq!(result.atom_count(), 2);
1196        let d = result.get(AtomIdx(0)).distance(&result.get(AtomIdx(1)));
1197        assert!(d > 1.4 && d < 1.7, "C-C should be ~1.54 Å, got {:.3}", d);
1198    }
1199
1200    #[test]
1201    fn test_minimize_mmff94_benzene() {
1202        let mol = parse("c1ccccc1").unwrap();
1203        let c = generate_coords(&mol);
1204        let result = minimize_mmff94(&mol, c);
1205        assert_eq!(result.atom_count(), 6);
1206        let min_d = all_pairs_min_dist(&result, 6);
1207        assert!(min_d > 1.2, "benzene clash: {min_d:.3}");
1208    }
1209
1210    #[test]
1211    fn test_minimize_mmff94_aspirin() {
1212        let mol = parse("CC(=O)Oc1ccccc1C(=O)O").unwrap();
1213        let c = generate_coords(&mol);
1214        let result = minimize_mmff94(&mol, c);
1215        // Verify minimize_mmff94 completes without error and produces valid coordinates
1216        assert_eq!(result.atom_count(), mol.atom_count());
1217        for i in 0..mol.atom_count() {
1218            let p = result.get(chematic_core::AtomIdx(i as u32));
1219            assert!(
1220                p.x.is_finite() && p.y.is_finite() && p.z.is_finite(),
1221                "aspirin atom {i} has invalid coords"
1222            );
1223        }
1224    }
1225
1226    // ===== Phase 2: Electrostatic Energy Integration (B5) =====
1227
1228    #[test]
1229    fn test_electrostatic_energy_methanol() {
1230        // Methanol has partial charges due to O-H polarity
1231        let mol = parse("CO").unwrap();
1232        let c = generate_coords(&mol);
1233        let mmff94_types = assign_mmff94_types(&mol).unwrap();
1234
1235        // Electrostatic energy should be calculable
1236        let elec_e = electrostatic_energy_mmff94(&mol, &c, &mmff94_types);
1237        assert!(elec_e.is_ok());
1238        assert!(elec_e.unwrap().is_finite());
1239    }
1240
1241    #[test]
1242    fn test_electrostatic_energy_carboxylic_acid() {
1243        // Carboxylic acids have significant charge separation
1244        let mol = parse("CC(=O)O").unwrap();
1245        let c = generate_coords(&mol);
1246        let mmff94_types = assign_mmff94_types(&mol).unwrap();
1247
1248        let elec_e = electrostatic_energy_mmff94(&mol, &c, &mmff94_types);
1249        assert!(elec_e.is_ok());
1250        let energy = elec_e.unwrap();
1251        assert!(energy.is_finite());
1252        // Carboxylic acids with negative oxygen should have non-zero electrostatic energy
1253    }
1254
1255    #[test]
1256    fn test_mmff94_with_electrostatic_ethane() {
1257        // Ethane is non-polar, so electrostatic should be small
1258        let mol = parse("CC").unwrap();
1259        let c = generate_coords(&mol);
1260        let result = minimize_mmff94(&mol, c);
1261
1262        // Should still minimize correctly with electrostatic term
1263        assert_eq!(result.atom_count(), 2);
1264        let d = result.get(AtomIdx(0)).distance(&result.get(AtomIdx(1)));
1265        assert!(
1266            d > 1.4 && d < 1.7,
1267            "ethane C-C should be ~1.54 Å with electrostatic, got {:.3}",
1268            d
1269        );
1270    }
1271
1272    #[test]
1273    fn test_mmff94_minimization_includes_charge_effects() {
1274        // Minimize polar molecule where charges should matter
1275        let mol = parse("CCO").unwrap();
1276        let c = generate_coords(&mol);
1277
1278        // Minimization should complete without error and produce valid coordinates
1279        let result = minimize_mmff94(&mol, c);
1280
1281        // All coordinates should be finite and properly positioned
1282        assert_eq!(result.atom_count(), 3);
1283        for i in 0..3 {
1284            let p = result.get(AtomIdx(i as u32));
1285            assert!(
1286                p.x.is_finite() && p.y.is_finite() && p.z.is_finite(),
1287                "atom {i} has invalid coordinate after minimization"
1288            );
1289        }
1290
1291        // Verify atoms are reasonably separated (no clashes)
1292        let c_c = result.get(AtomIdx(0)).distance(&result.get(AtomIdx(1)));
1293        let c_o = result.get(AtomIdx(1)).distance(&result.get(AtomIdx(2)));
1294        assert!(c_c > 1.0, "C-C bond too short: {c_c:.3}");
1295        assert!(c_o > 1.0, "C-O bond too short: {c_o:.3}");
1296    }
1297
1298    #[test]
1299    fn test_mmff94_charges_3d_integration() {
1300        // Verify that 3D charges are being used in minimization
1301        let mol = parse("c1ccccc1O").unwrap(); // Phenol
1302        let c = generate_coords(&mol);
1303
1304        // The minimization should complete without errors
1305        let result = minimize_mmff94(&mol, c);
1306        assert_eq!(result.atom_count(), mol.atom_count());
1307
1308        // All coordinates should be finite
1309        for i in 0..mol.atom_count() {
1310            let p = result.get(AtomIdx(i as u32));
1311            assert!(p.x.is_finite() && p.y.is_finite() && p.z.is_finite());
1312        }
1313    }
1314
1315    #[test]
1316    fn test_total_energy_mmff94_includes_electrostatic() {
1317        // Verify that total_energy_mmff94 includes electrostatic component
1318        let mol = parse("CCN").unwrap(); // Has C-N polarity
1319        let c = generate_coords(&mol);
1320        let mmff94_types = assign_mmff94_types(&mol).unwrap();
1321
1322        let total_e = total_energy_mmff94(&mol, &c, &mmff94_types);
1323        let bond_e = bond_energy_mmff94(&mol, &c, &mmff94_types);
1324        let angle_e = angle_energy_mmff94(&mol, &c, &mmff94_types);
1325        let vdw_e = vdw_energy_mmff94(&mol, &c, &mmff94_types);
1326
1327        // Total should include bond + angle + vdw + electrostatic
1328        let electrostatic_e = electrostatic_energy_mmff94(&mol, &c, &mmff94_types).unwrap_or(0.0);
1329        let expected = bond_e + angle_e + vdw_e + electrostatic_e;
1330
1331        assert!(
1332            (total_e - expected).abs() < 1e-6,
1333            "total energy mismatch: got {}, expected {}",
1334            total_e,
1335            expected
1336        );
1337    }
1338}