1use rand::rngs::StdRng;
2use rand::{Rng, SeedableRng};
3use serde::{Deserialize, Serialize};
4
5const AMU_ANGFS2_TO_KCAL_MOL: f64 = 2_390.057_361_533_49;
6const R_GAS_KCAL_MOLK: f64 = 0.001_987_204_258_640_83;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub enum MdBackend {
11 Uff,
13 Pm3,
15 Xtb,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct MdFrame {
22 pub step: usize,
24 pub time_fs: f64,
26 pub coords: Vec<f64>,
28 pub potential_energy_kcal_mol: f64,
30 pub kinetic_energy_kcal_mol: f64,
32 pub temperature_k: f64,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct MdTrajectory {
39 pub frames: Vec<MdFrame>,
41 pub dt_fs: f64,
43 pub notes: Vec<String>,
45 pub energy_drift_percent: Option<f64>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct NebImage {
53 pub index: usize,
55 pub coords: Vec<f64>,
57 pub potential_energy_kcal_mol: f64,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct NebPathResult {
64 pub images: Vec<NebImage>,
66 pub notes: Vec<String>,
68}
69
70fn atomic_mass_amu(z: u8) -> f64 {
71 match z {
72 1 => 1.008,
73 5 => 10.81,
74 6 => 12.011,
75 7 => 14.007,
76 8 => 15.999,
77 9 => 18.998,
78 14 => 28.085,
79 15 => 30.974,
80 16 => 32.06,
81 17 => 35.45,
82 35 => 79.904,
83 53 => 126.904,
84 26 => 55.845,
85 46 => 106.42,
86 78 => 195.084,
87 _ => 12.0,
88 }
89}
90
91fn sample_standard_normal(rng: &mut StdRng) -> f64 {
92 let u1 = (1.0 - rng.gen::<f64>()).max(1e-12);
93 let u2 = rng.gen::<f64>();
94 (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
95}
96
97fn kinetic_energy_and_temperature(
98 velocities: &[f64],
99 masses_amu: &[f64],
100 n_atoms: usize,
101) -> (f64, f64) {
102 let mut ke = 0.0;
103 for i in 0..n_atoms {
104 let vx = velocities[3 * i];
105 let vy = velocities[3 * i + 1];
106 let vz = velocities[3 * i + 2];
107 let v2 = vx * vx + vy * vy + vz * vz;
108 ke += 0.5 * masses_amu[i] * v2 * AMU_ANGFS2_TO_KCAL_MOL;
109 }
110 let dof = (3 * n_atoms).saturating_sub(6).max(1) as f64;
111 let t = 2.0 * ke / (dof * R_GAS_KCAL_MOLK);
112 (ke, t)
113}
114
115pub fn simulate_velocity_verlet_uff(
117 smiles: &str,
118 coords: &[f64],
119 n_steps: usize,
120 dt_fs: f64,
121 seed: u64,
122 target_temp_and_tau: Option<(f64, f64)>,
123) -> Result<MdTrajectory, String> {
124 if n_steps == 0 {
125 return Err("n_steps must be > 0".to_string());
126 }
127 if dt_fs <= 0.0 {
128 return Err("dt_fs must be > 0".to_string());
129 }
130
131 let mol = crate::graph::Molecule::from_smiles(smiles)?;
132 let n_atoms = mol.graph.node_count();
133 if coords.len() != n_atoms * 3 {
134 return Err(format!(
135 "coords length {} != 3 * atoms {}",
136 coords.len(),
137 n_atoms
138 ));
139 }
140
141 let masses_amu: Vec<f64> = (0..n_atoms)
142 .map(petgraph::graph::NodeIndex::new)
143 .map(|idx| atomic_mass_amu(mol.graph[idx].element))
144 .collect();
145
146 let ff = crate::forcefield::builder::build_uff_force_field(&mol);
147 let mut x = coords.to_vec();
148 let mut grad = vec![0.0f64; n_atoms * 3];
149 let mut potential = ff.compute_system_energy_and_gradients(&x, &mut grad);
150
151 let mut rng = StdRng::seed_from_u64(seed);
152 let mut v = vec![0.0f64; n_atoms * 3];
153
154 if let Some((target_temp_k, _tau_fs)) = target_temp_and_tau {
155 for i in 0..n_atoms {
156 let sigma = ((R_GAS_KCAL_MOLK * target_temp_k)
157 / (masses_amu[i] * AMU_ANGFS2_TO_KCAL_MOL))
158 .sqrt();
159 v[3 * i] = sigma * sample_standard_normal(&mut rng);
160 v[3 * i + 1] = sigma * sample_standard_normal(&mut rng);
161 v[3 * i + 2] = sigma * sample_standard_normal(&mut rng);
162 }
163 }
164
165 let (ke0, t0) = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
166 let mut frames = vec![MdFrame {
167 step: 0,
168 time_fs: 0.0,
169 coords: x.clone(),
170 potential_energy_kcal_mol: potential,
171 kinetic_energy_kcal_mol: ke0,
172 temperature_k: t0,
173 }];
174
175 for step in 1..=n_steps {
176 let mut a = vec![0.0f64; n_atoms * 3];
177 for i in 0..n_atoms {
178 let m = masses_amu[i];
179 a[3 * i] = -grad[3 * i] / (m * AMU_ANGFS2_TO_KCAL_MOL);
180 a[3 * i + 1] = -grad[3 * i + 1] / (m * AMU_ANGFS2_TO_KCAL_MOL);
181 a[3 * i + 2] = -grad[3 * i + 2] / (m * AMU_ANGFS2_TO_KCAL_MOL);
182 }
183
184 for i in 0..(n_atoms * 3) {
185 x[i] += v[i] * dt_fs + 0.5 * a[i] * dt_fs * dt_fs;
186 }
187
188 potential = ff.compute_system_energy_and_gradients(&x, &mut grad);
189
190 let mut a_new = vec![0.0f64; n_atoms * 3];
191 for i in 0..n_atoms {
192 let m = masses_amu[i];
193 a_new[3 * i] = -grad[3 * i] / (m * AMU_ANGFS2_TO_KCAL_MOL);
194 a_new[3 * i + 1] = -grad[3 * i + 1] / (m * AMU_ANGFS2_TO_KCAL_MOL);
195 a_new[3 * i + 2] = -grad[3 * i + 2] / (m * AMU_ANGFS2_TO_KCAL_MOL);
196 }
197
198 for i in 0..(n_atoms * 3) {
199 v[i] += 0.5 * (a[i] + a_new[i]) * dt_fs;
200 }
201
202 let (mut ke, mut temp_k) = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
203 if let Some((target_temp_k, tau_fs)) = target_temp_and_tau {
204 let tau = tau_fs.max(1e-6);
205 let lambda = (1.0 + (dt_fs / tau) * (target_temp_k / temp_k.max(1e-6) - 1.0)).sqrt();
206 let lambda = lambda.clamp(0.5, 2.0);
207 for vi in &mut v {
208 *vi *= lambda;
209 }
210 let kt = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
211 ke = kt.0;
212 temp_k = kt.1;
213 }
214
215 if !x.iter().all(|v| v.is_finite()) || !potential.is_finite() || !ke.is_finite() {
216 return Err(format!(
217 "MD diverged at step {} (non-finite coordinates/energy)",
218 step
219 ));
220 }
221
222 frames.push(MdFrame {
223 step,
224 time_fs: step as f64 * dt_fs,
225 coords: x.clone(),
226 potential_energy_kcal_mol: potential,
227 kinetic_energy_kcal_mol: ke,
228 temperature_k: temp_k,
229 });
230 }
231
232 let mut notes = vec![
233 "Velocity Verlet integration over UFF force-field gradients for short exploratory trajectories."
234 .to_string(),
235 ];
236 if target_temp_and_tau.is_some() {
237 notes.push(
238 "Berendsen thermostat rescaling applied for approximate constant-temperature sampling."
239 .to_string(),
240 );
241 } else {
242 notes.push(
243 "No thermostat applied (NVE-like propagation with current numerical approximations)."
244 .to_string(),
245 );
246 }
247
248 Ok(MdTrajectory {
249 energy_drift_percent: if target_temp_and_tau.is_none() {
250 let e0 = frames[0].potential_energy_kcal_mol + frames[0].kinetic_energy_kcal_mol;
251 let ef = frames
252 .last()
253 .map(|f| f.potential_energy_kcal_mol + f.kinetic_energy_kcal_mol)
254 .unwrap_or(e0);
255 if e0.abs() > 1e-10 {
256 Some((ef - e0) / e0.abs() * 100.0)
257 } else {
258 None
259 }
260 } else {
261 None
262 },
263 frames,
264 dt_fs,
265 notes,
266 })
267}
268
269pub fn compute_simplified_neb_path(
271 smiles: &str,
272 start_coords: &[f64],
273 end_coords: &[f64],
274 n_images: usize,
275 n_iter: usize,
276 spring_k: f64,
277 step_size: f64,
278) -> Result<NebPathResult, String> {
279 if n_images < 2 {
280 return Err("n_images must be >= 2".to_string());
281 }
282 if n_iter == 0 {
283 return Err("n_iter must be > 0".to_string());
284 }
285 if step_size <= 0.0 {
286 return Err("step_size must be > 0".to_string());
287 }
288
289 let mol = crate::graph::Molecule::from_smiles(smiles)?;
290 let n_atoms = mol.graph.node_count();
291 let n_xyz = n_atoms * 3;
292 if start_coords.len() != n_xyz || end_coords.len() != n_xyz {
293 return Err(format!(
294 "start/end coords must each have length {} (3 * n_atoms)",
295 n_xyz
296 ));
297 }
298
299 let ff = crate::forcefield::builder::build_uff_force_field(&mol);
300
301 let mut images = vec![vec![0.0f64; n_xyz]; n_images];
302 for (img_idx, img) in images.iter_mut().enumerate() {
303 let t = img_idx as f64 / (n_images - 1) as f64;
304 for k in 0..n_xyz {
305 img[k] = (1.0 - t) * start_coords[k] + t * end_coords[k];
306 }
307 }
308
309 for _ in 0..n_iter {
310 let prev = images.clone();
311 for i in 1..(n_images - 1) {
312 let mut grad = vec![0.0f64; n_xyz];
313 let _ = ff.compute_system_energy_and_gradients(&prev[i], &mut grad);
314
315 for k in 0..n_xyz {
316 let spring_force = spring_k * (prev[i + 1][k] - 2.0 * prev[i][k] + prev[i - 1][k]);
317 let total_force = -grad[k] + spring_force;
318 images[i][k] = prev[i][k] + step_size * total_force;
319 }
320 }
321 }
322
323 let mut out_images = Vec::with_capacity(n_images);
324 for (i, coords) in images.into_iter().enumerate() {
325 let mut grad = vec![0.0f64; n_xyz];
326 let e = ff.compute_system_energy_and_gradients(&coords, &mut grad);
327 out_images.push(NebImage {
328 index: i,
329 coords,
330 potential_energy_kcal_mol: e,
331 });
332 }
333
334 Ok(NebPathResult {
335 images: out_images,
336 notes: vec![
337 "Simplified NEB: linear interpolation + spring-coupled UFF gradient relaxation on internal images."
338 .to_string(),
339 "This is a low-cost exploratory path tool and not a full climbing-image / tangent-projected NEB implementation."
340 .to_string(),
341 ],
342 })
343}
344
345fn compute_backend_energy_and_gradients(
349 backend: MdBackend,
350 smiles: &str,
351 elements: &[u8],
352 coords: &[f64],
353 grad: &mut [f64],
354) -> Result<f64, String> {
355 match backend {
356 MdBackend::Uff => {
357 let mol = crate::graph::Molecule::from_smiles(smiles)?;
358 let ff = crate::forcefield::builder::build_uff_force_field(&mol);
359 let e = ff.compute_system_energy_and_gradients(coords, grad);
360 Ok(e)
361 }
362 MdBackend::Pm3 => {
363 let n = elements.len();
364 let positions: Vec<[f64; 3]> = coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
365 let result = crate::pm3::solver::solve_pm3(elements, &positions)?;
366 let energy_kcal = result.total_energy * 23.0605;
368 let step = 1e-4;
370 for i in 0..(n * 3) {
371 let mut x_plus = coords.to_vec();
372 let mut x_minus = coords.to_vec();
373 x_plus[i] += step;
374 x_minus[i] -= step;
375 let pos_p: Vec<[f64; 3]> = x_plus.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
376 let pos_m: Vec<[f64; 3]> = x_minus.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
377 let e_p = crate::pm3::solver::solve_pm3(elements, &pos_p)?.total_energy * 23.0605;
378 let e_m = crate::pm3::solver::solve_pm3(elements, &pos_m)?.total_energy * 23.0605;
379 grad[i] = (e_p - e_m) / (2.0 * step);
380 }
381 Ok(energy_kcal)
382 }
383 MdBackend::Xtb => {
384 let n = elements.len();
385 let positions: Vec<[f64; 3]> = coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
386 let result = crate::xtb::solver::solve_xtb(elements, &positions)?;
387 let energy_kcal = result.total_energy * 23.0605;
388 let step = 1e-4;
389 for i in 0..(n * 3) {
390 let mut x_plus = coords.to_vec();
391 let mut x_minus = coords.to_vec();
392 x_plus[i] += step;
393 x_minus[i] -= step;
394 let pos_p: Vec<[f64; 3]> = x_plus.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
395 let pos_m: Vec<[f64; 3]> = x_minus.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
396 let e_p = crate::xtb::solver::solve_xtb(elements, &pos_p)?.total_energy * 23.0605;
397 let e_m = crate::xtb::solver::solve_xtb(elements, &pos_m)?.total_energy * 23.0605;
398 grad[i] = (e_p - e_m) / (2.0 * step);
399 }
400 Ok(energy_kcal)
401 }
402 }
403}
404
405pub fn simulate_velocity_verlet(
410 smiles: &str,
411 coords: &[f64],
412 elements: &[u8],
413 n_steps: usize,
414 dt_fs: f64,
415 seed: u64,
416 target_temp_and_tau: Option<(f64, f64)>,
417 backend: MdBackend,
418) -> Result<MdTrajectory, String> {
419 if n_steps == 0 {
420 return Err("n_steps must be > 0".to_string());
421 }
422 let n_atoms = elements.len();
423 if coords.len() != n_atoms * 3 {
424 return Err(format!(
425 "coords length {} != 3*atoms {}",
426 coords.len(),
427 n_atoms
428 ));
429 }
430
431 let masses_amu: Vec<f64> = elements.iter().map(|&z| atomic_mass_amu(z)).collect();
432
433 let mut x = coords.to_vec();
434 let mut grad = vec![0.0f64; n_atoms * 3];
435 let mut potential =
436 compute_backend_energy_and_gradients(backend, smiles, elements, &x, &mut grad)?;
437
438 let mut rng = StdRng::seed_from_u64(seed);
439 let mut v = vec![0.0f64; n_atoms * 3];
440
441 if let Some((target_temp_k, _)) = target_temp_and_tau {
442 for i in 0..n_atoms {
443 let sigma = ((R_GAS_KCAL_MOLK * target_temp_k)
444 / (masses_amu[i] * AMU_ANGFS2_TO_KCAL_MOL))
445 .sqrt();
446 v[3 * i] = sigma * sample_standard_normal(&mut rng);
447 v[3 * i + 1] = sigma * sample_standard_normal(&mut rng);
448 v[3 * i + 2] = sigma * sample_standard_normal(&mut rng);
449 }
450 }
451
452 let (ke0, t0) = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
453 let mut frames = vec![MdFrame {
454 step: 0,
455 time_fs: 0.0,
456 coords: x.clone(),
457 potential_energy_kcal_mol: potential,
458 kinetic_energy_kcal_mol: ke0,
459 temperature_k: t0,
460 }];
461
462 for step in 1..=n_steps {
463 let mut a = vec![0.0f64; n_atoms * 3];
464 for i in 0..n_atoms {
465 let m = masses_amu[i];
466 for k in 0..3 {
467 a[3 * i + k] = -grad[3 * i + k] / (m * AMU_ANGFS2_TO_KCAL_MOL);
468 }
469 }
470
471 for i in 0..(n_atoms * 3) {
472 x[i] += v[i] * dt_fs + 0.5 * a[i] * dt_fs * dt_fs;
473 }
474
475 potential = compute_backend_energy_and_gradients(backend, smiles, elements, &x, &mut grad)?;
476
477 let mut a_new = vec![0.0f64; n_atoms * 3];
478 for i in 0..n_atoms {
479 let m = masses_amu[i];
480 for k in 0..3 {
481 a_new[3 * i + k] = -grad[3 * i + k] / (m * AMU_ANGFS2_TO_KCAL_MOL);
482 }
483 }
484
485 for i in 0..(n_atoms * 3) {
486 v[i] += 0.5 * (a[i] + a_new[i]) * dt_fs;
487 }
488
489 let (mut ke, mut temp_k) = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
490 if let Some((target_temp_k, tau_fs)) = target_temp_and_tau {
491 let tau = tau_fs.max(1e-6);
492 let lambda = (1.0 + (dt_fs / tau) * (target_temp_k / temp_k.max(1e-6) - 1.0)).sqrt();
493 let lambda = lambda.clamp(0.5, 2.0);
494 for vi in &mut v {
495 *vi *= lambda;
496 }
497 let kt = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
498 ke = kt.0;
499 temp_k = kt.1;
500 }
501
502 if !x.iter().all(|v| v.is_finite()) || !potential.is_finite() {
503 return Err(format!("MD diverged at step {}", step));
504 }
505
506 frames.push(MdFrame {
507 step,
508 time_fs: step as f64 * dt_fs,
509 coords: x.clone(),
510 potential_energy_kcal_mol: potential,
511 kinetic_energy_kcal_mol: ke,
512 temperature_k: temp_k,
513 });
514 }
515
516 let energy_drift_percent = if target_temp_and_tau.is_none() {
517 let e0 = frames[0].potential_energy_kcal_mol + frames[0].kinetic_energy_kcal_mol;
518 let ef = frames
519 .last()
520 .map(|f| f.potential_energy_kcal_mol + f.kinetic_energy_kcal_mol)
521 .unwrap_or(e0);
522 if e0.abs() > 1e-10 {
523 Some((ef - e0) / e0.abs() * 100.0)
524 } else {
525 None
526 }
527 } else {
528 None
529 };
530
531 Ok(MdTrajectory {
532 frames,
533 dt_fs,
534 notes: vec![format!("Velocity Verlet with {:?} backend.", backend)],
535 energy_drift_percent,
536 })
537}
538
539pub fn simulate_nose_hoover(
544 smiles: &str,
545 coords: &[f64],
546 elements: &[u8],
547 n_steps: usize,
548 dt_fs: f64,
549 target_temp_k: f64,
550 thermostat_mass: f64,
551 chain_length: usize,
552 seed: u64,
553 backend: MdBackend,
554) -> Result<MdTrajectory, String> {
555 if n_steps == 0 {
556 return Err("n_steps must be > 0".to_string());
557 }
558 let n_atoms = elements.len();
559 if coords.len() != n_atoms * 3 {
560 return Err("coords length mismatch".to_string());
561 }
562
563 let masses_amu: Vec<f64> = elements.iter().map(|&z| atomic_mass_amu(z)).collect();
564 let dof = (3 * n_atoms).saturating_sub(6).max(1) as f64;
565 let target_ke = 0.5 * dof * R_GAS_KCAL_MOLK * target_temp_k;
566
567 let mut x = coords.to_vec();
568 let mut grad = vec![0.0f64; n_atoms * 3];
569 let mut potential =
570 compute_backend_energy_and_gradients(backend, smiles, elements, &x, &mut grad)?;
571
572 let mut rng = StdRng::seed_from_u64(seed);
573 let mut v = vec![0.0f64; n_atoms * 3];
574 for i in 0..n_atoms {
575 let sigma =
576 ((R_GAS_KCAL_MOLK * target_temp_k) / (masses_amu[i] * AMU_ANGFS2_TO_KCAL_MOL)).sqrt();
577 v[3 * i] = sigma * sample_standard_normal(&mut rng);
578 v[3 * i + 1] = sigma * sample_standard_normal(&mut rng);
579 v[3 * i + 2] = sigma * sample_standard_normal(&mut rng);
580 }
581
582 let chain_len = chain_length.max(1);
584 let q = vec![thermostat_mass; chain_len]; let mut xi = vec![0.0f64; chain_len]; let mut v_xi = vec![0.0f64; chain_len]; let (ke0, t0) = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
589 let mut frames = vec![MdFrame {
590 step: 0,
591 time_fs: 0.0,
592 coords: x.clone(),
593 potential_energy_kcal_mol: potential,
594 kinetic_energy_kcal_mol: ke0,
595 temperature_k: t0,
596 }];
597
598 let dt2 = dt_fs * 0.5;
599 let dt4 = dt_fs * 0.25;
600
601 for step in 1..=n_steps {
602 let (ke, _) = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
603
604 let mut g_xi = vec![0.0f64; chain_len];
607 g_xi[0] = (2.0 * ke - 2.0 * target_ke) / q[0];
608 for j in 1..chain_len {
609 g_xi[j] =
610 (q[j - 1] * v_xi[j - 1] * v_xi[j - 1] - R_GAS_KCAL_MOLK * target_temp_k) / q[j];
611 }
612
613 if chain_len > 1 {
615 v_xi[chain_len - 1] += g_xi[chain_len - 1] * dt4;
616 }
617 for j in (0..chain_len.saturating_sub(1)).rev() {
618 let exp_factor = (-v_xi[j + 1] * dt4).exp();
619 v_xi[j] = v_xi[j] * exp_factor + g_xi[j] * dt4;
620 }
621
622 let scale = (-v_xi[0] * dt2).exp();
624 for vi in v.iter_mut() {
625 *vi *= scale;
626 }
627
628 for j in 0..chain_len {
630 xi[j] += v_xi[j] * dt2;
631 }
632
633 let (ke_scaled, _) = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
635 g_xi[0] = (2.0 * ke_scaled - 2.0 * target_ke) / q[0];
636
637 for j in 0..chain_len.saturating_sub(1) {
639 let exp_factor = (-v_xi[j + 1] * dt4).exp();
640 v_xi[j] = v_xi[j] * exp_factor + g_xi[j] * dt4;
641 }
642 if chain_len > 1 {
643 g_xi[chain_len - 1] = (q[chain_len - 2] * v_xi[chain_len - 2] * v_xi[chain_len - 2]
645 - R_GAS_KCAL_MOLK * target_temp_k)
646 / q[chain_len - 1];
647 v_xi[chain_len - 1] += g_xi[chain_len - 1] * dt4;
648 }
649
650 for i in 0..n_atoms {
652 let m = masses_amu[i];
653 for k in 0..3 {
654 v[3 * i + k] -= 0.5 * dt_fs * grad[3 * i + k] / (m * AMU_ANGFS2_TO_KCAL_MOL);
655 }
656 }
657
658 for i in 0..(n_atoms * 3) {
660 x[i] += v[i] * dt_fs;
661 }
662
663 potential = compute_backend_energy_and_gradients(backend, smiles, elements, &x, &mut grad)?;
665
666 for i in 0..n_atoms {
668 let m = masses_amu[i];
669 for k in 0..3 {
670 v[3 * i + k] -= 0.5 * dt_fs * grad[3 * i + k] / (m * AMU_ANGFS2_TO_KCAL_MOL);
671 }
672 }
673
674 let (ke_final, temp_k) = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
675
676 if !x.iter().all(|v| v.is_finite()) || !potential.is_finite() {
677 return Err(format!("NH-MD diverged at step {}", step));
678 }
679
680 frames.push(MdFrame {
681 step,
682 time_fs: step as f64 * dt_fs,
683 coords: x.clone(),
684 potential_energy_kcal_mol: potential,
685 kinetic_energy_kcal_mol: ke_final,
686 temperature_k: temp_k,
687 });
688 }
689
690 let temps: Vec<f64> = frames
692 .iter()
693 .skip(frames.len() / 5)
694 .map(|f| f.temperature_k)
695 .collect();
696 let avg_temp = temps.iter().sum::<f64>() / temps.len() as f64;
697 let temp_variance =
698 temps.iter().map(|t| (t - avg_temp).powi(2)).sum::<f64>() / temps.len() as f64;
699
700 Ok(MdTrajectory {
701 frames,
702 dt_fs,
703 notes: vec![
704 format!(
705 "Nosé-Hoover chain thermostat (chain={}) with {:?} backend.",
706 chain_len, backend
707 ),
708 format!(
709 "Target T = {:.1} K, avg T = {:.1} K, σ(T) = {:.1} K",
710 target_temp_k,
711 avg_temp,
712 temp_variance.sqrt()
713 ),
714 ],
715 energy_drift_percent: None,
716 })
717}
718
719pub fn trajectory_to_xyz(trajectory: &MdTrajectory, elements: &[u8]) -> String {
724 let n_atoms = elements.len();
725 let mut output = String::new();
726
727 for frame in &trajectory.frames {
728 output.push_str(&format!("{}\n", n_atoms));
729 output.push_str(&format!(
730 "step={} t={:.1}fs E_pot={:.4} E_kin={:.4} T={:.1}K\n",
731 frame.step,
732 frame.time_fs,
733 frame.potential_energy_kcal_mol,
734 frame.kinetic_energy_kcal_mol,
735 frame.temperature_k,
736 ));
737 for i in 0..n_atoms {
738 let sym = element_symbol(elements[i]);
739 output.push_str(&format!(
740 "{} {:.6} {:.6} {:.6}\n",
741 sym,
742 frame.coords[3 * i],
743 frame.coords[3 * i + 1],
744 frame.coords[3 * i + 2],
745 ));
746 }
747 }
748 output
749}
750
751fn element_symbol(z: u8) -> &'static str {
752 match z {
753 1 => "H",
754 2 => "He",
755 3 => "Li",
756 4 => "Be",
757 5 => "B",
758 6 => "C",
759 7 => "N",
760 8 => "O",
761 9 => "F",
762 10 => "Ne",
763 11 => "Na",
764 12 => "Mg",
765 13 => "Al",
766 14 => "Si",
767 15 => "P",
768 16 => "S",
769 17 => "Cl",
770 18 => "Ar",
771 19 => "K",
772 20 => "Ca",
773 22 => "Ti",
774 24 => "Cr",
775 25 => "Mn",
776 26 => "Fe",
777 27 => "Co",
778 28 => "Ni",
779 29 => "Cu",
780 30 => "Zn",
781 35 => "Br",
782 44 => "Ru",
783 46 => "Pd",
784 47 => "Ag",
785 53 => "I",
786 78 => "Pt",
787 79 => "Au",
788 _ => "X",
789 }
790}