Skip to main content

sci_form/
dynamics.rs

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/// Force backend for molecular dynamics.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub enum MdBackend {
11    /// UFF force field (fast, approximate).
12    Uff,
13    /// PM3 semi-empirical (slower, more accurate).
14    Pm3,
15    /// GFN-xTB tight-binding (moderate speed, good for metals).
16    Xtb,
17}
18
19/// One trajectory frame for molecular-dynamics sampling.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct MdFrame {
22    /// Integration step index.
23    pub step: usize,
24    /// Elapsed simulation time in femtoseconds.
25    pub time_fs: f64,
26    /// Flat xyz coordinates in angstroms.
27    pub coords: Vec<f64>,
28    /// Potential energy from UFF in kcal/mol.
29    pub potential_energy_kcal_mol: f64,
30    /// Kinetic energy in kcal/mol.
31    pub kinetic_energy_kcal_mol: f64,
32    /// Instantaneous temperature estimate in K.
33    pub temperature_k: f64,
34}
35
36/// Full trajectory output for exploratory molecular dynamics.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct MdTrajectory {
39    /// Stored MD frames.
40    pub frames: Vec<MdFrame>,
41    /// Timestep in femtoseconds.
42    pub dt_fs: f64,
43    /// Notes and caveats for interpretation.
44    pub notes: Vec<String>,
45    /// Energy conservation drift: (E_total_final - E_total_initial) / |E_total_initial| * 100%.
46    /// Only meaningful for NVE (no thermostat) simulations.
47    pub energy_drift_percent: Option<f64>,
48}
49
50/// One image (node) on a simplified NEB path.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct NebImage {
53    /// Image index from reactant (0) to product (n-1).
54    pub index: usize,
55    /// Flat xyz coordinates in angstroms.
56    pub coords: Vec<f64>,
57    /// UFF potential energy in kcal/mol.
58    pub potential_energy_kcal_mol: f64,
59}
60
61/// Simplified NEB output for low-cost pathway exploration.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct NebPathResult {
64    /// Ordered path images from reactant to product.
65    pub images: Vec<NebImage>,
66    /// Notes and caveats.
67    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
115/// Run short exploratory molecular dynamics using Velocity Verlet and optional Berendsen NVT.
116pub 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
269/// Build a simplified NEB-like path using linear interpolation and spring-coupled relaxation.
270pub 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
345/// Compute energy and gradients using the specified backend.
346///
347/// Returns (energy_kcal_mol, gradients) or an error.
348fn 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            // PM3 gives energy in eV; convert to kcal/mol
367            let energy_kcal = result.total_energy * 23.0605;
368            // Numerical gradients via finite differences
369            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
405/// Run molecular dynamics using Velocity Verlet with a configurable force backend.
406///
407/// Supports UFF, PM3, and xTB backends. Note that PM3/xTB use numerical gradients
408/// and are significantly slower than UFF.
409pub 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
539/// Nosé-Hoover chain thermostat for rigorous NVT sampling.
540///
541/// Implements a chain of `chain_length` thermostats coupled to velocities.
542/// Produces canonical (NVT) ensemble with correct fluctuations.
543pub 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    // Nosé-Hoover chain variables
583    let chain_len = chain_length.max(1);
584    let q = vec![thermostat_mass; chain_len]; // thermostat masses
585    let mut xi = vec![0.0f64; chain_len]; // thermostat positions
586    let mut v_xi = vec![0.0f64; chain_len]; // thermostat velocities
587
588    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        // Nosé-Hoover chain: propagate thermostat (Yoshida-Suzuki-like)
605        // Force on first thermostat
606        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        // Update thermostat velocities (outside-in)
614        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        // Scale velocities
623        let scale = (-v_xi[0] * dt2).exp();
624        for vi in v.iter_mut() {
625            *vi *= scale;
626        }
627
628        // Update thermostat positions
629        for j in 0..chain_len {
630            xi[j] += v_xi[j] * dt2;
631        }
632
633        // Recompute KE after scaling
634        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        // Update thermostat velocities again (inside-out)
638        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            // Recompute force on last thermostat
644            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        // Velocity Verlet: half-step velocity
651        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        // Position update
659        for i in 0..(n_atoms * 3) {
660            x[i] += v[i] * dt_fs;
661        }
662
663        // New forces
664        potential = compute_backend_energy_and_gradients(backend, smiles, elements, &x, &mut grad)?;
665
666        // Velocity Verlet: second half-step velocity
667        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    // Temperature analysis
691    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
719/// Export an MD trajectory to XYZ format string.
720///
721/// Each frame becomes a block with atom count, comment line (step/energy/temp),
722/// and atom coordinates.
723pub 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}