1use 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; const K_COULOMB: f64 = 332.0637; #[derive(Clone, Debug)]
19pub enum Thermostat {
20 None,
22 Berendsen { tau_fs: f64 },
24}
25
26#[derive(Clone, Debug)]
28pub struct MDConfig {
29 pub timestep_fs: f64,
31 pub steps: usize,
33 pub temperature_k: f64,
35 pub thermostat: Thermostat,
37 pub save_every: usize,
39 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#[derive(Clone, Debug)]
58pub struct MDFrame {
59 pub step: usize,
61 pub coords: Coords3D,
63 pub potential_energy: f64,
65 pub kinetic_energy: f64,
67 pub temperature_k: f64,
69}
70
71#[derive(Clone, Debug)]
73pub struct MDTrajectory {
74 pub frames: Vec<MDFrame>,
76}
77
78pub 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 let masses = get_atom_masses(mol);
88
89 let charges = if config.coulomb {
91 gasteiger_charges(mol)
92 } else {
93 vec![0.0; mol.atom_count()]
94 };
95
96 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 let forces = compute_forces(mol, ¤t_coords, &charges, config.coulomb);
107
108 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 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 let new_forces = compute_forces(mol, ¤t_coords, &charges, config.coulomb);
130
131 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 let current_pot_energy = total_energy(mol, ¤t_coords, &charges, config.coulomb);
143
144 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 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 const UNIT_CONVERSION: f64 = 0.01038; (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; 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; 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 let types = assign_dreiding_types(mol);
341
342 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 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 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 let (r_i, eps_i) = dreiding_vdw(types[i]);
382 let (r_j, eps_j) = dreiding_vdw(types[j]);
383
384 let sigma = 0.5 * (r_i + r_j);
386 let epsilon = (eps_i * eps_j).sqrt();
387
388 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; 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); }
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 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}