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/// One trajectory frame for molecular-dynamics sampling.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct MdFrame {
11    /// Integration step index.
12    pub step: usize,
13    /// Elapsed simulation time in femtoseconds.
14    pub time_fs: f64,
15    /// Flat xyz coordinates in angstroms.
16    pub coords: Vec<f64>,
17    /// Potential energy from UFF in kcal/mol.
18    pub potential_energy_kcal_mol: f64,
19    /// Kinetic energy in kcal/mol.
20    pub kinetic_energy_kcal_mol: f64,
21    /// Instantaneous temperature estimate in K.
22    pub temperature_k: f64,
23}
24
25/// Full trajectory output for exploratory molecular dynamics.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct MdTrajectory {
28    /// Stored MD frames.
29    pub frames: Vec<MdFrame>,
30    /// Timestep in femtoseconds.
31    pub dt_fs: f64,
32    /// Notes and caveats for interpretation.
33    pub notes: Vec<String>,
34}
35
36/// One image (node) on a simplified NEB path.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct NebImage {
39    /// Image index from reactant (0) to product (n-1).
40    pub index: usize,
41    /// Flat xyz coordinates in angstroms.
42    pub coords: Vec<f64>,
43    /// UFF potential energy in kcal/mol.
44    pub potential_energy_kcal_mol: f64,
45}
46
47/// Simplified NEB output for low-cost pathway exploration.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct NebPathResult {
50    /// Ordered path images from reactant to product.
51    pub images: Vec<NebImage>,
52    /// Notes and caveats.
53    pub notes: Vec<String>,
54}
55
56fn atomic_mass_amu(z: u8) -> f64 {
57    match z {
58        1 => 1.008,
59        5 => 10.81,
60        6 => 12.011,
61        7 => 14.007,
62        8 => 15.999,
63        9 => 18.998,
64        14 => 28.085,
65        15 => 30.974,
66        16 => 32.06,
67        17 => 35.45,
68        35 => 79.904,
69        53 => 126.904,
70        26 => 55.845,
71        46 => 106.42,
72        78 => 195.084,
73        _ => 12.0,
74    }
75}
76
77fn sample_standard_normal(rng: &mut StdRng) -> f64 {
78    let u1 = (1.0 - rng.gen::<f64>()).max(1e-12);
79    let u2 = rng.gen::<f64>();
80    (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
81}
82
83fn kinetic_energy_and_temperature(
84    velocities: &[f64],
85    masses_amu: &[f64],
86    n_atoms: usize,
87) -> (f64, f64) {
88    let mut ke = 0.0;
89    for i in 0..n_atoms {
90        let vx = velocities[3 * i];
91        let vy = velocities[3 * i + 1];
92        let vz = velocities[3 * i + 2];
93        let v2 = vx * vx + vy * vy + vz * vz;
94        ke += 0.5 * masses_amu[i] * v2 * AMU_ANGFS2_TO_KCAL_MOL;
95    }
96    let dof = (3 * n_atoms).saturating_sub(6).max(1) as f64;
97    let t = 2.0 * ke / (dof * R_GAS_KCAL_MOLK);
98    (ke, t)
99}
100
101/// Run short exploratory molecular dynamics using Velocity Verlet and optional Berendsen NVT.
102pub fn simulate_velocity_verlet_uff(
103    smiles: &str,
104    coords: &[f64],
105    n_steps: usize,
106    dt_fs: f64,
107    seed: u64,
108    target_temp_and_tau: Option<(f64, f64)>,
109) -> Result<MdTrajectory, String> {
110    if n_steps == 0 {
111        return Err("n_steps must be > 0".to_string());
112    }
113    if dt_fs <= 0.0 {
114        return Err("dt_fs must be > 0".to_string());
115    }
116
117    let mol = crate::graph::Molecule::from_smiles(smiles)?;
118    let n_atoms = mol.graph.node_count();
119    if coords.len() != n_atoms * 3 {
120        return Err(format!(
121            "coords length {} != 3 * atoms {}",
122            coords.len(),
123            n_atoms
124        ));
125    }
126
127    let masses_amu: Vec<f64> = (0..n_atoms)
128        .map(petgraph::graph::NodeIndex::new)
129        .map(|idx| atomic_mass_amu(mol.graph[idx].element))
130        .collect();
131
132    let ff = crate::forcefield::builder::build_uff_force_field(&mol);
133    let mut x = coords.to_vec();
134    let mut grad = vec![0.0f64; n_atoms * 3];
135    let mut potential = ff.compute_system_energy_and_gradients(&x, &mut grad);
136
137    let mut rng = StdRng::seed_from_u64(seed);
138    let mut v = vec![0.0f64; n_atoms * 3];
139
140    if let Some((target_temp_k, _tau_fs)) = target_temp_and_tau {
141        for i in 0..n_atoms {
142            let sigma = ((R_GAS_KCAL_MOLK * target_temp_k)
143                / (masses_amu[i] * AMU_ANGFS2_TO_KCAL_MOL))
144                .sqrt();
145            v[3 * i] = sigma * sample_standard_normal(&mut rng);
146            v[3 * i + 1] = sigma * sample_standard_normal(&mut rng);
147            v[3 * i + 2] = sigma * sample_standard_normal(&mut rng);
148        }
149    }
150
151    let (ke0, t0) = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
152    let mut frames = vec![MdFrame {
153        step: 0,
154        time_fs: 0.0,
155        coords: x.clone(),
156        potential_energy_kcal_mol: potential,
157        kinetic_energy_kcal_mol: ke0,
158        temperature_k: t0,
159    }];
160
161    for step in 1..=n_steps {
162        let mut a = vec![0.0f64; n_atoms * 3];
163        for i in 0..n_atoms {
164            let m = masses_amu[i];
165            a[3 * i] = -grad[3 * i] / (m * AMU_ANGFS2_TO_KCAL_MOL);
166            a[3 * i + 1] = -grad[3 * i + 1] / (m * AMU_ANGFS2_TO_KCAL_MOL);
167            a[3 * i + 2] = -grad[3 * i + 2] / (m * AMU_ANGFS2_TO_KCAL_MOL);
168        }
169
170        for i in 0..(n_atoms * 3) {
171            x[i] += v[i] * dt_fs + 0.5 * a[i] * dt_fs * dt_fs;
172        }
173
174        potential = ff.compute_system_energy_and_gradients(&x, &mut grad);
175
176        let mut a_new = vec![0.0f64; n_atoms * 3];
177        for i in 0..n_atoms {
178            let m = masses_amu[i];
179            a_new[3 * i] = -grad[3 * i] / (m * AMU_ANGFS2_TO_KCAL_MOL);
180            a_new[3 * i + 1] = -grad[3 * i + 1] / (m * AMU_ANGFS2_TO_KCAL_MOL);
181            a_new[3 * i + 2] = -grad[3 * i + 2] / (m * AMU_ANGFS2_TO_KCAL_MOL);
182        }
183
184        for i in 0..(n_atoms * 3) {
185            v[i] += 0.5 * (a[i] + a_new[i]) * dt_fs;
186        }
187
188        let (mut ke, mut temp_k) = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
189        if let Some((target_temp_k, tau_fs)) = target_temp_and_tau {
190            let tau = tau_fs.max(1e-6);
191            let lambda = (1.0 + (dt_fs / tau) * (target_temp_k / temp_k.max(1e-6) - 1.0)).sqrt();
192            let lambda = lambda.clamp(0.5, 2.0);
193            for vi in &mut v {
194                *vi *= lambda;
195            }
196            let kt = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
197            ke = kt.0;
198            temp_k = kt.1;
199        }
200
201        if !x.iter().all(|v| v.is_finite()) || !potential.is_finite() || !ke.is_finite() {
202            return Err(format!(
203                "MD diverged at step {} (non-finite coordinates/energy)",
204                step
205            ));
206        }
207
208        frames.push(MdFrame {
209            step,
210            time_fs: step as f64 * dt_fs,
211            coords: x.clone(),
212            potential_energy_kcal_mol: potential,
213            kinetic_energy_kcal_mol: ke,
214            temperature_k: temp_k,
215        });
216    }
217
218    let mut notes = vec![
219        "Velocity Verlet integration over UFF force-field gradients for short exploratory trajectories."
220            .to_string(),
221    ];
222    if target_temp_and_tau.is_some() {
223        notes.push(
224            "Berendsen thermostat rescaling applied for approximate constant-temperature sampling."
225                .to_string(),
226        );
227    } else {
228        notes.push(
229            "No thermostat applied (NVE-like propagation with current numerical approximations)."
230                .to_string(),
231        );
232    }
233
234    Ok(MdTrajectory {
235        frames,
236        dt_fs,
237        notes,
238    })
239}
240
241/// Build a simplified NEB-like path using linear interpolation and spring-coupled relaxation.
242pub fn compute_simplified_neb_path(
243    smiles: &str,
244    start_coords: &[f64],
245    end_coords: &[f64],
246    n_images: usize,
247    n_iter: usize,
248    spring_k: f64,
249    step_size: f64,
250) -> Result<NebPathResult, String> {
251    if n_images < 2 {
252        return Err("n_images must be >= 2".to_string());
253    }
254    if n_iter == 0 {
255        return Err("n_iter must be > 0".to_string());
256    }
257    if step_size <= 0.0 {
258        return Err("step_size must be > 0".to_string());
259    }
260
261    let mol = crate::graph::Molecule::from_smiles(smiles)?;
262    let n_atoms = mol.graph.node_count();
263    let n_xyz = n_atoms * 3;
264    if start_coords.len() != n_xyz || end_coords.len() != n_xyz {
265        return Err(format!(
266            "start/end coords must each have length {} (3 * n_atoms)",
267            n_xyz
268        ));
269    }
270
271    let ff = crate::forcefield::builder::build_uff_force_field(&mol);
272
273    let mut images = vec![vec![0.0f64; n_xyz]; n_images];
274    for (img_idx, img) in images.iter_mut().enumerate() {
275        let t = img_idx as f64 / (n_images - 1) as f64;
276        for k in 0..n_xyz {
277            img[k] = (1.0 - t) * start_coords[k] + t * end_coords[k];
278        }
279    }
280
281    for _ in 0..n_iter {
282        let prev = images.clone();
283        for i in 1..(n_images - 1) {
284            let mut grad = vec![0.0f64; n_xyz];
285            let _ = ff.compute_system_energy_and_gradients(&prev[i], &mut grad);
286
287            for k in 0..n_xyz {
288                let spring_force = spring_k * (prev[i + 1][k] - 2.0 * prev[i][k] + prev[i - 1][k]);
289                let total_force = -grad[k] + spring_force;
290                images[i][k] = prev[i][k] + step_size * total_force;
291            }
292        }
293    }
294
295    let mut out_images = Vec::with_capacity(n_images);
296    for (i, coords) in images.into_iter().enumerate() {
297        let mut grad = vec![0.0f64; n_xyz];
298        let e = ff.compute_system_energy_and_gradients(&coords, &mut grad);
299        out_images.push(NebImage {
300            index: i,
301            coords,
302            potential_energy_kcal_mol: e,
303        });
304    }
305
306    Ok(NebPathResult {
307        images: out_images,
308        notes: vec![
309            "Simplified NEB: linear interpolation + spring-coupled UFF gradient relaxation on internal images."
310                .to_string(),
311            "This is a low-cost exploratory path tool and not a full climbing-image / tangent-projected NEB implementation."
312                .to_string(),
313        ],
314    })
315}