Skip to main content

chematic_3d/
md.rs

1//! Molecular dynamics simulation using Velocity Verlet integration.
2//!
3//! Provides NVE (microcanonical) and NVT (constant temperature) simulations
4//! with optional Berendsen thermostat control. Compatible with WASM.
5
6use std::f64;
7
8use chematic_chem::gasteiger_charges;
9use chematic_core::Molecule;
10use chematic_ff::{assign_dreiding_types, dreiding_vdw};
11
12use crate::coords::{Coords3D, Point3};
13
14const K_BOLTZMANN: f64 = 0.0019872041; // kcal/(mol·K)
15const K_COULOMB: f64 = 332.0637; // kcal·Å/(mol·e²)
16
17/// Thermostat control method.
18#[derive(Clone, Debug)]
19pub enum Thermostat {
20    /// No thermostat (NVE ensemble).
21    None,
22    /// Berendsen thermostat with coupling constant τ (fs).
23    Berendsen { tau_fs: f64 },
24}
25
26/// Configuration for molecular dynamics simulation.
27#[derive(Clone, Debug)]
28pub struct MDConfig {
29    /// Timestep in femtoseconds.
30    pub timestep_fs: f64,
31    /// Number of integration steps.
32    pub steps: usize,
33    /// Target temperature (K).
34    pub temperature_k: f64,
35    /// Thermostat control method.
36    pub thermostat: Thermostat,
37    /// Save trajectory frame every N steps.
38    pub save_every: usize,
39    /// Include Coulomb interactions.
40    pub coulomb: bool,
41}
42
43impl Default for MDConfig {
44    fn default() -> Self {
45        Self {
46            timestep_fs: 1.0,
47            steps: 100,
48            temperature_k: 300.0,
49            thermostat: Thermostat::Berendsen { tau_fs: 100.0 },
50            save_every: 10,
51            coulomb: true,
52        }
53    }
54}
55
56/// A snapshot of the system at a given timestep.
57#[derive(Clone, Debug)]
58pub struct MDFrame {
59    /// Timestep index.
60    pub step: usize,
61    /// Atomic coordinates (Å).
62    pub coords: Coords3D,
63    /// Potential energy (kcal/mol).
64    pub potential_energy: f64,
65    /// Kinetic energy (kcal/mol).
66    pub kinetic_energy: f64,
67    /// Instantaneous temperature (K).
68    pub temperature_k: f64,
69}
70
71/// Complete MD trajectory.
72#[derive(Clone, Debug)]
73pub struct MDTrajectory {
74    /// Frames saved at regular intervals.
75    pub frames: Vec<MDFrame>,
76}
77
78/// Run molecular dynamics simulation.
79pub fn run_md(mol: &Molecule, coords: Coords3D, config: &MDConfig) -> MDTrajectory {
80    if mol.atom_count() == 0 {
81        return MDTrajectory { frames: vec![] };
82    }
83
84    let dt = config.timestep_fs / 1000.0;
85
86    // Get atom masses (approximation: use element default)
87    let masses = get_atom_masses(mol);
88
89    // Get charges if Coulomb is enabled
90    let charges = if config.coulomb {
91        gasteiger_charges(mol)
92    } else {
93        vec![0.0; mol.atom_count()]
94    };
95
96    // Initialize velocities from Maxwell-Boltzmann distribution
97    let mut prng = crate::prng::Prng::new();
98    let mut velocities = initialize_velocities(&masses, config.temperature_k, &mut prng);
99
100    let mut trajectory = MDTrajectory { frames: vec![] };
101
102    let mut current_coords = coords;
103
104    for step in 0..config.steps {
105        // Compute forces (finite differences from energy)
106        let forces = compute_forces(mol, &current_coords, &charges, config.coulomb);
107
108        // Velocity Verlet: half-step velocity update
109        for i in 0..mol.atom_count() {
110            let m = masses[i];
111            if m > 0.0 {
112                velocities[i].x += 0.5 * forces[i].x / m * dt;
113                velocities[i].y += 0.5 * forces[i].y / m * dt;
114                velocities[i].z += 0.5 * forces[i].z / m * dt;
115            }
116        }
117
118        // Position update
119        for i in 0..mol.atom_count() {
120            let m = masses[i];
121            let mut p = current_coords.get(chematic_core::AtomIdx(i as u32));
122            p.x += velocities[i].x * dt + 0.5 * forces[i].x / m * dt * dt;
123            p.y += velocities[i].y * dt + 0.5 * forces[i].y / m * dt * dt;
124            p.z += velocities[i].z * dt + 0.5 * forces[i].z / m * dt * dt;
125            current_coords.set(chematic_core::AtomIdx(i as u32), p);
126        }
127
128        // Recompute forces at new position
129        let new_forces = compute_forces(mol, &current_coords, &charges, config.coulomb);
130
131        // Complete velocity update
132        for i in 0..mol.atom_count() {
133            let m = masses[i];
134            if m > 0.0 {
135                velocities[i].x += 0.5 * new_forces[i].x / m * dt;
136                velocities[i].y += 0.5 * new_forces[i].y / m * dt;
137                velocities[i].z += 0.5 * new_forces[i].z / m * dt;
138            }
139        }
140
141        // Calculate potential energy for frame output
142        let current_pot_energy = total_energy(mol, &current_coords, &charges, config.coulomb);
143
144        // Apply thermostat
145        let (_kinetic_energy, temperature) =
146            compute_kinetic_energy_and_temp(&velocities, &masses, config.temperature_k);
147
148        match config.thermostat {
149            Thermostat::None => {}
150            Thermostat::Berendsen { tau_fs } => {
151                let lambda = if temperature < 1e-6 {
152                    1.0
153                } else {
154                    let tau = tau_fs / 1000.0;
155                    (1.0 + (dt / tau) * (config.temperature_k / temperature - 1.0)).sqrt()
156                };
157                for v in &mut velocities {
158                    v.x *= lambda;
159                    v.y *= lambda;
160                    v.z *= lambda;
161                }
162            }
163        }
164
165        // Save frame
166        if (step + 1) % config.save_every == 0 {
167            let (ke, temp) =
168                compute_kinetic_energy_and_temp(&velocities, &masses, config.temperature_k);
169            trajectory.frames.push(MDFrame {
170                step: step + 1,
171                coords: current_coords.clone(),
172                potential_energy: current_pot_energy,
173                kinetic_energy: ke,
174                temperature_k: temp,
175            });
176        }
177    }
178
179    trajectory
180}
181
182fn get_atom_masses(mol: &Molecule) -> Vec<f64> {
183    mol.atoms()
184        .map(|(_, atom)| {
185            let z = atom.element.atomic_number();
186            match z {
187                1 => 1.008,
188                6 => 12.011,
189                7 => 14.007,
190                8 => 15.999,
191                9 => 18.998,
192                15 => 30.974,
193                16 => 32.06,
194                17 => 35.45,
195                35 => 79.904,
196                53 => 126.90,
197                _ => 12.0,
198            }
199        })
200        .collect()
201}
202
203fn initialize_velocities(masses: &[f64], temp_k: f64, prng: &mut crate::prng::Prng) -> Vec<Point3> {
204    // Maxwell-Boltzmann distribution: v_i ~ sqrt(k_B * T / m)
205    // Unit conversion: 1 kcal/mol = 0.01038 amu·Ų/fs²
206    // sigma = sqrt(k_B * T * 0.01038 / m) [Å/fs]
207    const UNIT_CONVERSION: f64 = 0.01038; // kcal/mol → amu·Ų/fs²
208
209    (0..masses.len())
210        .map(|i| {
211            let m = masses[i];
212            if m > 1e-10 {
213                let sigma_sq = K_BOLTZMANN * temp_k * UNIT_CONVERSION / m;
214                let sigma = sigma_sq.sqrt();
215                Point3::new(
216                    gaussian_random(prng) * sigma,
217                    gaussian_random(prng) * sigma,
218                    gaussian_random(prng) * sigma,
219                )
220            } else {
221                Point3::zero()
222            }
223        })
224        .collect()
225}
226
227fn gaussian_random(prng: &mut crate::prng::Prng) -> f64 {
228    prng.gaussian_f64()
229}
230
231fn compute_forces(
232    mol: &Molecule,
233    coords: &Coords3D,
234    charges: &[f64],
235    coulomb: bool,
236) -> Vec<Point3> {
237    let delta = 1e-4;
238    let mut forces = vec![Point3::zero(); mol.atom_count()];
239
240    fn energy_at_delta(
241        mol: &Molecule,
242        coords: &Coords3D,
243        idx: chematic_core::AtomIdx,
244        delta: f64,
245        axis: impl Fn(&mut Point3, f64),
246        charges: &[f64],
247        coulomb: bool,
248    ) -> f64 {
249        let orig = coords.get(idx);
250        let mut p = orig;
251        axis(&mut p, delta);
252        let mut c = coords.clone();
253        c.set(idx, p);
254        let ep = total_energy(mol, &c, charges, coulomb);
255
256        let mut p = orig;
257        axis(&mut p, -delta);
258        c.set(idx, p);
259        let em = total_energy(mol, &c, charges, coulomb);
260
261        (ep - em) / (2.0 * delta)
262    }
263
264    for i in 0..mol.atom_count() {
265        let idx = chematic_core::AtomIdx(i as u32);
266        forces[i].x = energy_at_delta(mol, coords, idx, delta, |p, d| p.x += d, charges, coulomb);
267        forces[i].y = energy_at_delta(mol, coords, idx, delta, |p, d| p.y += d, charges, coulomb);
268        forces[i].z = energy_at_delta(mol, coords, idx, delta, |p, d| p.z += d, charges, coulomb);
269    }
270
271    forces
272}
273
274fn total_energy(mol: &Molecule, coords: &Coords3D, charges: &[f64], coulomb: bool) -> f64 {
275    bond_energy(mol, coords)
276        + angle_energy(mol, coords)
277        + vdw_energy(mol, coords)
278        + if coulomb {
279            coulomb_energy(coords, charges)
280        } else {
281            0.0
282        }
283}
284
285fn bond_energy(mol: &Molecule, coords: &Coords3D) -> f64 {
286    let mut energy = 0.0;
287    let k = 700.0; // Force constant (kcal/mol/Ų)
288    for (_, bond) in mol.bonds() {
289        let a1 = bond.atom1;
290        let a2 = bond.atom2;
291        let r = coords.get(a1).distance(&coords.get(a2));
292        let sym1 = mol.atom(a1).element.symbol();
293        let sym2 = mol.atom(a2).element.symbol();
294        let ideal = ideal_bond_len(sym1, sym2, bond.order);
295        let delta = r - ideal;
296        energy += 0.5 * k * delta * delta;
297    }
298    energy
299}
300
301fn angle_energy(mol: &Molecule, coords: &Coords3D) -> f64 {
302    let mut energy = 0.0;
303    let k = 100.0; // Force constant (kcal/mol/rad²)
304
305    for b_idx in 0..mol.atom_count() {
306        let b = chematic_core::AtomIdx(b_idx as u32);
307        let neighbors: Vec<_> = mol.neighbors(b).map(|(nb, _)| nb).collect();
308
309        if neighbors.len() < 2 {
310            continue;
311        }
312
313        for (i, &na) in neighbors.iter().enumerate() {
314            for &nb in &neighbors[i + 1..] {
315                let pa = coords.get(na);
316                let pb = coords.get(b);
317                let pc = coords.get(nb);
318
319                let v1 = Point3::new(pa.x - pb.x, pa.y - pb.y, pa.z - pb.z);
320                let v2 = Point3::new(pc.x - pb.x, pc.y - pb.y, pc.z - pb.z);
321                let angle = angle_from_vectors(&v1, &v2);
322
323                let sym = mol.atom(b).element.symbol();
324                let hyb = atom_hybridization(mol, b);
325                let ideal = ideal_angle_rad(sym, hyb);
326
327                let delta = angle - ideal;
328                energy += 0.5 * k * delta * delta;
329            }
330        }
331    }
332    energy
333}
334
335fn vdw_energy(mol: &Molecule, coords: &Coords3D) -> f64 {
336    let mut energy = 0.0;
337    let n = mol.atom_count();
338
339    // Assign DREIDING types to all atoms
340    let types = assign_dreiding_types(mol);
341
342    // Build exclusion list: skip bonded pairs (1-2 and 1-3)
343    let mut excluded = std::collections::HashSet::new();
344    for (_, bond) in mol.bonds() {
345        let i = bond.atom1.0 as usize;
346        let j = bond.atom2.0 as usize;
347        excluded.insert((i.min(j), i.max(j)));
348    }
349    // Also exclude 1-3 pairs (atoms separated by one bond)
350    let bonds_vec: Vec<_> = mol.bonds().collect();
351    for (_, bond1) in &bonds_vec {
352        for (_, bond2) in &bonds_vec {
353            if bond1.atom2 == bond2.atom1 {
354                let i = bond1.atom1.0 as usize;
355                let k = bond2.atom2.0 as usize;
356                if i != k {
357                    excluded.insert((i.min(k), i.max(k)));
358                }
359            }
360        }
361    }
362
363    for i in 0..n {
364        for j in (i + 1)..n {
365            // Skip excluded pairs
366            if excluded.contains(&(i, j)) {
367                continue;
368            }
369
370            let ii = chematic_core::AtomIdx(i as u32);
371            let jj = chematic_core::AtomIdx(j as u32);
372            let pi = coords.get(ii);
373            let pj = coords.get(jj);
374            let r = pi.distance(&pj);
375
376            if r < 0.1 {
377                continue;
378            }
379
380            // Get VDW parameters from DREIDING
381            let (r_i, eps_i) = dreiding_vdw(types[i]);
382            let (r_j, eps_j) = dreiding_vdw(types[j]);
383
384            // Lorentz-Berthelot combining rules
385            let sigma = 0.5 * (r_i + r_j);
386            let epsilon = (eps_i * eps_j).sqrt();
387
388            // Lennard-Jones 12-6: E = eps * [(sigma/r)^12 - 2*(sigma/r)^6]
389            let sig_r = sigma / r;
390            let sig_r6 = sig_r * sig_r * sig_r * sig_r * sig_r * sig_r;
391            let sig_r12 = sig_r6 * sig_r6;
392
393            energy += epsilon * (sig_r12 - 2.0 * sig_r6);
394        }
395    }
396    energy
397}
398
399fn ideal_bond_len(sym1: &str, sym2: &str, order: chematic_core::BondOrder) -> f64 {
400    use chematic_core::BondOrder;
401    let (a, b) = if sym1 <= sym2 {
402        (sym1, sym2)
403    } else {
404        (sym2, sym1)
405    };
406    match (a, b, order) {
407        ("C", "C", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.540,
408        ("C", "C", BondOrder::Double) => 1.340,
409        ("C", "C", BondOrder::Triple) => 1.204,
410        ("C", "C", BondOrder::Aromatic) => 1.395,
411        ("C", "H", _) => 1.090,
412        ("C", "N", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.469,
413        ("C", "N", BondOrder::Double) => 1.279,
414        ("C", "N", BondOrder::Triple) => 1.158,
415        ("C", "N", BondOrder::Aromatic) => 1.340,
416        ("C", "O", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.427,
417        ("C", "O", BondOrder::Double) => 1.217,
418        ("C", "O", BondOrder::Aromatic) => 1.355,
419        ("C", "S", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.819,
420        ("H", "N", _) => 1.010,
421        ("H", "O", _) => 0.960,
422        ("N", "N", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.450,
423        ("N", "N", BondOrder::Double) => 1.252,
424        ("N", "N", BondOrder::Triple) => 1.098,
425        ("N", "O", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.463,
426        ("N", "O", BondOrder::Double) => 1.240,
427        ("O", "O", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.450,
428        ("O", "O", BondOrder::Double) => 1.207,
429        _ => 1.5,
430    }
431}
432
433#[derive(Clone, Copy, PartialEq, Debug)]
434enum Hybridization {
435    SP,
436    SP2,
437    SP3,
438}
439
440fn atom_hybridization(mol: &Molecule, idx: chematic_core::AtomIdx) -> Hybridization {
441    use chematic_core::BondOrder;
442    if mol.atom(idx).aromatic {
443        return Hybridization::SP2;
444    }
445    let mut has_triple = false;
446    let mut has_double_or_aromatic = false;
447    for (_, bond_idx) in mol.neighbors(idx) {
448        match mol.bond(bond_idx).order {
449            BondOrder::Triple => has_triple = true,
450            BondOrder::Double | BondOrder::Aromatic => has_double_or_aromatic = true,
451            _ => {}
452        }
453    }
454    if has_triple {
455        Hybridization::SP
456    } else if has_double_or_aromatic {
457        Hybridization::SP2
458    } else {
459        Hybridization::SP3
460    }
461}
462
463fn ideal_angle_rad(sym: &str, hyb: Hybridization) -> f64 {
464    match hyb {
465        Hybridization::SP => 180.0_f64.to_radians(),
466        Hybridization::SP2 => 120.0_f64.to_radians(),
467        Hybridization::SP3 => match sym {
468            "O" | "Se" => 104.5_f64.to_radians(),
469            "N" => 107.0_f64.to_radians(),
470            "S" => 99.0_f64.to_radians(),
471            "P" => 93.0_f64.to_radians(),
472            _ => 109.47_f64.to_radians(),
473        },
474    }
475}
476
477fn angle_from_vectors(v1: &Point3, v2: &Point3) -> f64 {
478    let dot = v1.x * v2.x + v1.y * v2.y + v1.z * v2.z;
479    let mag1 = (v1.x * v1.x + v1.y * v1.y + v1.z * v1.z).sqrt();
480    let mag2 = (v2.x * v2.x + v2.y * v2.y + v2.z * v2.z).sqrt();
481    if mag1 > 1e-10 && mag2 > 1e-10 {
482        let cos_angle = (dot / (mag1 * mag2)).clamp(-1.0, 1.0);
483        cos_angle.acos()
484    } else {
485        0.0
486    }
487}
488
489fn coulomb_energy(coords: &Coords3D, charges: &[f64]) -> f64 {
490    let mut energy = 0.0;
491    let n = coords.atom_count();
492    for i in 0..n {
493        for j in (i + 1)..n {
494            let pi = coords.get(chematic_core::AtomIdx(i as u32));
495            let pj = coords.get(chematic_core::AtomIdx(j as u32));
496            let r = pi.distance(&pj);
497            if r > 1e-6 {
498                energy += K_COULOMB * charges[i] * charges[j] / r;
499            }
500        }
501    }
502    energy
503}
504
505fn compute_kinetic_energy_and_temp(
506    velocities: &[Point3],
507    masses: &[f64],
508    _target_temp: f64,
509) -> (f64, f64) {
510    let mut ke = 0.0;
511    for (i, v) in velocities.iter().enumerate() {
512        let speed_sq = v.x * v.x + v.y * v.y + v.z * v.z;
513        ke += 0.5 * masses[i] * speed_sq;
514    }
515    let dof = (3 * velocities.len() - 3).max(1) as f64; // 3N - 3 translational
516    let temp = 2.0 * ke / (dof * K_BOLTZMANN);
517    (ke, temp)
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use chematic_smiles::parse;
524
525    #[test]
526    fn test_md_ethane_nve() {
527        let mol = parse("CC").expect("ethane");
528        let coords = crate::generate_coords(&mol);
529        let config = MDConfig {
530            timestep_fs: 0.5,
531            steps: 50,
532            temperature_k: 300.0,
533            thermostat: Thermostat::None,
534            save_every: 10,
535            coulomb: false,
536        };
537        let traj = run_md(&mol, coords, &config);
538        assert!(!traj.frames.is_empty());
539        assert!(traj.frames[0].potential_energy > -1e10); // Sanity check
540    }
541
542    #[test]
543    fn test_md_ethane_nvt() {
544        let mol = parse("CC").expect("ethane");
545        let coords = crate::generate_coords(&mol);
546        let config = MDConfig {
547            timestep_fs: 0.5,
548            steps: 100,
549            temperature_k: 300.0,
550            thermostat: Thermostat::Berendsen { tau_fs: 50.0 },
551            save_every: 20,
552            coulomb: false,
553        };
554        let traj = run_md(&mol, coords, &config);
555        assert!(!traj.frames.is_empty());
556        // Check that simulation completes without crashing
557        let final_frame = traj.frames.last().unwrap();
558        assert!(final_frame.temperature_k > 0.0);
559    }
560
561    #[test]
562    fn test_berendsen_zero_temperature_no_nan() {
563        let mol = parse("C").expect("methane");
564        let coords = crate::generate_coords(&mol);
565        let config = MDConfig {
566            timestep_fs: 1.0,
567            steps: 1,
568            temperature_k: 300.0,
569            thermostat: Thermostat::Berendsen { tau_fs: 100.0 },
570            save_every: 1,
571            coulomb: false,
572        };
573        let traj = run_md(&mol, coords, &config);
574        assert!(!traj.frames.is_empty());
575        let frame = traj.frames.first().unwrap();
576        for (i, coord) in frame.coords.points.iter().enumerate() {
577            assert!(coord.x.is_finite(), "coord[{}].x is NaN", i);
578            assert!(coord.y.is_finite(), "coord[{}].y is NaN", i);
579            assert!(coord.z.is_finite(), "coord[{}].z is NaN", i);
580        }
581    }
582}