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, Serialize, Deserialize)]
10pub struct MdFrame {
11 pub step: usize,
13 pub time_fs: f64,
15 pub coords: Vec<f64>,
17 pub potential_energy_kcal_mol: f64,
19 pub kinetic_energy_kcal_mol: f64,
21 pub temperature_k: f64,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct MdTrajectory {
28 pub frames: Vec<MdFrame>,
30 pub dt_fs: f64,
32 pub notes: Vec<String>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct NebImage {
39 pub index: usize,
41 pub coords: Vec<f64>,
43 pub potential_energy_kcal_mol: f64,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct NebPathResult {
50 pub images: Vec<NebImage>,
52 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
101pub 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
241pub 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}