use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use serde::{Deserialize, Serialize};
const AMU_ANGFS2_TO_KCAL_MOL: f64 = 2_390.057_361_533_49;
const R_GAS_KCAL_MOLK: f64 = 0.001_987_204_258_640_83;
const EV_TO_KCAL_MOL: f64 = 23.060_5;
const HARTREE_TO_KCAL_MOL: f64 = 627.509_474_063_1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MdBackend {
Uff,
Pm3,
Xtb,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NebBackend {
Uff,
Mmff94,
Pm3,
Xtb,
Gfn1,
Gfn2,
Hf3c,
}
impl NebBackend {
pub fn from_method(s: &str) -> Result<Self, String> {
match s.trim().to_ascii_lowercase().as_str() {
"uff" => Ok(Self::Uff),
"mmff94" | "mmff" => Ok(Self::Mmff94),
"pm3" => Ok(Self::Pm3),
"xtb" | "gfn0" | "gfn0-xtb" | "gfn0_xtb" => Ok(Self::Xtb),
"gfn1" | "gfn1-xtb" | "gfn1_xtb" => Ok(Self::Gfn1),
"gfn2" | "gfn2-xtb" | "gfn2_xtb" => Ok(Self::Gfn2),
"hf3c" | "hf-3c" => Ok(Self::Hf3c),
other => Err(format!(
"Unknown NEB backend '{}'. Expected: uff, mmff94, pm3, xtb, gfn1, gfn2, hf3c",
other
)),
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Uff => "uff",
Self::Mmff94 => "mmff94",
Self::Pm3 => "pm3",
Self::Xtb => "xtb",
Self::Gfn1 => "gfn1",
Self::Gfn2 => "gfn2",
Self::Hf3c => "hf3c",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MdFrame {
pub step: usize,
pub time_fs: f64,
pub coords: Vec<f64>,
pub potential_energy_kcal_mol: f64,
pub kinetic_energy_kcal_mol: f64,
pub temperature_k: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MdTrajectory {
pub frames: Vec<MdFrame>,
pub dt_fs: f64,
pub notes: Vec<String>,
pub energy_drift_percent: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NebImage {
pub index: usize,
pub coords: Vec<f64>,
pub potential_energy_kcal_mol: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NebPathResult {
pub images: Vec<NebImage>,
pub notes: Vec<String>,
}
pub fn atomic_mass_amu(z: u8) -> f64 {
match z {
1 => 1.008,
5 => 10.81,
6 => 12.011,
7 => 14.007,
8 => 15.999,
9 => 18.998,
14 => 28.085,
15 => 30.974,
16 => 32.06,
17 => 35.45,
35 => 79.904,
53 => 126.904,
26 => 55.845,
46 => 106.42,
78 => 195.084,
_ => 12.0,
}
}
fn sample_standard_normal(rng: &mut StdRng) -> f64 {
let u1 = (1.0 - rng.gen::<f64>()).max(1e-12);
let u2 = rng.gen::<f64>();
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
}
fn kinetic_energy_and_temperature(
velocities: &[f64],
masses_amu: &[f64],
n_atoms: usize,
) -> (f64, f64) {
let mut ke = 0.0;
for i in 0..n_atoms {
let vx = velocities[3 * i];
let vy = velocities[3 * i + 1];
let vz = velocities[3 * i + 2];
let v2 = vx * vx + vy * vy + vz * vz;
ke += 0.5 * masses_amu[i] * v2 * AMU_ANGFS2_TO_KCAL_MOL;
}
let dof = (3 * n_atoms).saturating_sub(6).max(1) as f64;
let t = 2.0 * ke / (dof * R_GAS_KCAL_MOLK);
(ke, t)
}
pub fn simulate_velocity_verlet_uff(
smiles: &str,
coords: &[f64],
n_steps: usize,
dt_fs: f64,
seed: u64,
target_temp_and_tau: Option<(f64, f64)>,
) -> Result<MdTrajectory, String> {
if n_steps == 0 {
return Err("n_steps must be > 0".to_string());
}
if dt_fs <= 0.0 {
return Err("dt_fs must be > 0".to_string());
}
let mol = crate::graph::Molecule::from_smiles(smiles)?;
let n_atoms = mol.graph.node_count();
if coords.len() != n_atoms * 3 {
return Err(format!(
"coords length {} != 3 * atoms {}",
coords.len(),
n_atoms
));
}
let masses_amu: Vec<f64> = (0..n_atoms)
.map(petgraph::graph::NodeIndex::new)
.map(|idx| atomic_mass_amu(mol.graph[idx].element))
.collect();
let ff = crate::forcefield::builder::build_uff_force_field(&mol);
let mut x = coords.to_vec();
let mut grad = vec![0.0f64; n_atoms * 3];
let mut potential = ff.compute_system_energy_and_gradients(&x, &mut grad);
let mut rng = StdRng::seed_from_u64(seed);
let mut v = vec![0.0f64; n_atoms * 3];
if let Some((target_temp_k, _tau_fs)) = target_temp_and_tau {
for i in 0..n_atoms {
let sigma = ((R_GAS_KCAL_MOLK * target_temp_k)
/ (masses_amu[i] * AMU_ANGFS2_TO_KCAL_MOL))
.sqrt();
v[3 * i] = sigma * sample_standard_normal(&mut rng);
v[3 * i + 1] = sigma * sample_standard_normal(&mut rng);
v[3 * i + 2] = sigma * sample_standard_normal(&mut rng);
}
}
let (ke0, t0) = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
let mut frames = vec![MdFrame {
step: 0,
time_fs: 0.0,
coords: x.clone(),
potential_energy_kcal_mol: potential,
kinetic_energy_kcal_mol: ke0,
temperature_k: t0,
}];
for step in 1..=n_steps {
let mut a = vec![0.0f64; n_atoms * 3];
for i in 0..n_atoms {
let m = masses_amu[i];
a[3 * i] = -grad[3 * i] / (m * AMU_ANGFS2_TO_KCAL_MOL);
a[3 * i + 1] = -grad[3 * i + 1] / (m * AMU_ANGFS2_TO_KCAL_MOL);
a[3 * i + 2] = -grad[3 * i + 2] / (m * AMU_ANGFS2_TO_KCAL_MOL);
}
for i in 0..(n_atoms * 3) {
x[i] += v[i] * dt_fs + 0.5 * a[i] * dt_fs * dt_fs;
}
potential = ff.compute_system_energy_and_gradients(&x, &mut grad);
let mut a_new = vec![0.0f64; n_atoms * 3];
for i in 0..n_atoms {
let m = masses_amu[i];
a_new[3 * i] = -grad[3 * i] / (m * AMU_ANGFS2_TO_KCAL_MOL);
a_new[3 * i + 1] = -grad[3 * i + 1] / (m * AMU_ANGFS2_TO_KCAL_MOL);
a_new[3 * i + 2] = -grad[3 * i + 2] / (m * AMU_ANGFS2_TO_KCAL_MOL);
}
for i in 0..(n_atoms * 3) {
v[i] += 0.5 * (a[i] + a_new[i]) * dt_fs;
}
let (mut ke, mut temp_k) = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
if let Some((target_temp_k, tau_fs)) = target_temp_and_tau {
let tau = tau_fs.max(1e-6);
let lambda = (1.0 + (dt_fs / tau) * (target_temp_k / temp_k.max(1e-6) - 1.0)).sqrt();
let lambda = lambda.clamp(0.5, 2.0);
for vi in &mut v {
*vi *= lambda;
}
let kt = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
ke = kt.0;
temp_k = kt.1;
}
if !x.iter().all(|v| v.is_finite()) || !potential.is_finite() || !ke.is_finite() {
return Err(format!(
"MD diverged at step {} (non-finite coordinates/energy)",
step
));
}
frames.push(MdFrame {
step,
time_fs: step as f64 * dt_fs,
coords: x.clone(),
potential_energy_kcal_mol: potential,
kinetic_energy_kcal_mol: ke,
temperature_k: temp_k,
});
}
let mut notes = vec![
"Velocity Verlet integration over UFF force-field gradients for short exploratory trajectories."
.to_string(),
];
if target_temp_and_tau.is_some() {
notes.push(
"Berendsen thermostat rescaling applied for approximate constant-temperature sampling."
.to_string(),
);
} else {
notes.push(
"No thermostat applied (NVE-like propagation with current numerical approximations)."
.to_string(),
);
}
Ok(MdTrajectory {
energy_drift_percent: if target_temp_and_tau.is_none() {
let e0 = frames[0].potential_energy_kcal_mol + frames[0].kinetic_energy_kcal_mol;
let ef = frames
.last()
.map(|f| f.potential_energy_kcal_mol + f.kinetic_energy_kcal_mol)
.unwrap_or(e0);
if e0.abs() > 1e-10 {
Some((ef - e0) / e0.abs() * 100.0)
} else {
None
}
} else {
None
},
frames,
dt_fs,
notes,
})
}
pub fn compute_simplified_neb_path(
smiles: &str,
start_coords: &[f64],
end_coords: &[f64],
n_images: usize,
n_iter: usize,
spring_k: f64,
step_size: f64,
) -> Result<NebPathResult, String> {
if n_images < 2 {
return Err("n_images must be >= 2".to_string());
}
if n_iter == 0 {
return Err("n_iter must be > 0".to_string());
}
if step_size <= 0.0 {
return Err("step_size must be > 0".to_string());
}
let mol = crate::graph::Molecule::from_smiles(smiles)?;
let n_atoms = mol.graph.node_count();
let n_xyz = n_atoms * 3;
if start_coords.len() != n_xyz || end_coords.len() != n_xyz {
return Err(format!(
"start/end coords must each have length {} (3 * n_atoms)",
n_xyz
));
}
let ff = crate::forcefield::builder::build_uff_force_field(&mol);
let mut images = vec![vec![0.0f64; n_xyz]; n_images];
for (img_idx, img) in images.iter_mut().enumerate() {
let t = img_idx as f64 / (n_images - 1) as f64;
for k in 0..n_xyz {
img[k] = (1.0 - t) * start_coords[k] + t * end_coords[k];
}
}
for _ in 0..n_iter {
let prev = images.clone();
#[cfg(feature = "parallel")]
{
use rayon::prelude::*;
let updated: Vec<(usize, Vec<f64>)> = (1..(n_images - 1))
.into_par_iter()
.map(|i| {
let ff_local = crate::forcefield::builder::build_uff_force_field(&mol);
let mut grad = vec![0.0f64; n_xyz];
let _ = ff_local.compute_system_energy_and_gradients(&prev[i], &mut grad);
let mut new_img = prev[i].clone();
for k in 0..n_xyz {
let spring_force =
spring_k * (prev[i + 1][k] - 2.0 * prev[i][k] + prev[i - 1][k]);
let total_force = -grad[k] + spring_force;
new_img[k] = prev[i][k] + step_size * total_force;
}
(i, new_img)
})
.collect();
for (i, img) in updated {
images[i] = img;
}
}
#[cfg(not(feature = "parallel"))]
{
for i in 1..(n_images - 1) {
let mut grad = vec![0.0f64; n_xyz];
let _ = ff.compute_system_energy_and_gradients(&prev[i], &mut grad);
for k in 0..n_xyz {
let spring_force =
spring_k * (prev[i + 1][k] - 2.0 * prev[i][k] + prev[i - 1][k]);
let total_force = -grad[k] + spring_force;
images[i][k] = prev[i][k] + step_size * total_force;
}
}
}
}
let mut out_images = Vec::with_capacity(n_images);
for (i, coords) in images.into_iter().enumerate() {
let mut grad = vec![0.0f64; n_xyz];
let e = ff.compute_system_energy_and_gradients(&coords, &mut grad);
out_images.push(NebImage {
index: i,
coords,
potential_energy_kcal_mol: e,
});
}
Ok(NebPathResult {
images: out_images,
notes: vec![
"Simplified NEB: linear interpolation + spring-coupled UFF gradient relaxation on internal images."
.to_string(),
"This is a low-cost exploratory path tool and not a full climbing-image / tangent-projected NEB implementation."
.to_string(),
],
})
}
fn neb_point_energy_kcal(
backend: NebBackend,
elements: &[u8],
coords: &[f64],
) -> Result<f64, String> {
let positions: Vec<[f64; 3]> = coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
match backend {
NebBackend::Gfn1 => {
let r = crate::xtb::gfn1::solve_gfn1(elements, &positions)?;
Ok(r.total_energy * EV_TO_KCAL_MOL)
}
NebBackend::Gfn2 => {
let r = crate::xtb::gfn2::solve_gfn2(elements, &positions)?;
Ok(r.total_energy * EV_TO_KCAL_MOL)
}
NebBackend::Hf3c => {
let r = crate::hf::solve_hf3c(elements, &positions, &crate::hf::HfConfig::default())?;
Ok(r.energy * HARTREE_TO_KCAL_MOL)
}
_ => unreachable!("neb_point_energy_kcal only for energy-only backends"),
}
}
fn neb_numerical_gradient(
backend: NebBackend,
elements: &[u8],
coords: &[f64],
grad: &mut [f64],
) -> Result<f64, String> {
let delta = 1e-5; let e0 = neb_point_energy_kcal(backend, elements, coords)?;
#[cfg(feature = "parallel")]
{
use rayon::prelude::*;
let n = coords.len();
let results: Vec<Result<f64, String>> = (0..n)
.into_par_iter()
.map(|i| {
let mut displaced = coords.to_vec();
displaced[i] = coords[i] + delta;
let e_plus = neb_point_energy_kcal(backend, elements, &displaced)?;
displaced[i] = coords[i] - delta;
let e_minus = neb_point_energy_kcal(backend, elements, &displaced)?;
Ok((e_plus - e_minus) / (2.0 * delta))
})
.collect();
for (i, r) in results.into_iter().enumerate() {
grad[i] = r?;
}
}
#[cfg(not(feature = "parallel"))]
{
let mut displaced = coords.to_vec();
for i in 0..coords.len() {
displaced[i] = coords[i] + delta;
let e_plus = neb_point_energy_kcal(backend, elements, &displaced)?;
displaced[i] = coords[i] - delta;
let e_minus = neb_point_energy_kcal(backend, elements, &displaced)?;
displaced[i] = coords[i]; grad[i] = (e_plus - e_minus) / (2.0 * delta);
}
}
Ok(e0)
}
pub fn neb_energy_and_gradient(
backend: NebBackend,
_smiles: &str,
elements: &[u8],
mol: &crate::graph::Molecule,
coords: &[f64],
grad: &mut [f64],
) -> Result<f64, String> {
match backend {
NebBackend::Uff => {
let ff = crate::forcefield::builder::build_uff_force_field(mol);
Ok(ff.compute_system_energy_and_gradients(coords, grad))
}
NebBackend::Mmff94 => {
let bonds: Vec<(usize, usize, u8)> = mol
.graph
.edge_indices()
.map(|e| {
let (a, b) = mol.graph.edge_endpoints(e).unwrap();
let order = match mol.graph[e].order {
crate::graph::BondOrder::Single => 1u8,
crate::graph::BondOrder::Double => 2,
crate::graph::BondOrder::Triple => 3,
crate::graph::BondOrder::Aromatic => 2,
crate::graph::BondOrder::Unknown => 1,
};
(a.index(), b.index(), order)
})
.collect();
let terms = crate::forcefield::mmff94::Mmff94Builder::build(elements, &bonds);
let (energy, g) =
crate::forcefield::mmff94::Mmff94Builder::total_energy(&terms, coords);
grad[..g.len()].copy_from_slice(&g);
Ok(energy)
}
NebBackend::Pm3 => {
let positions: Vec<[f64; 3]> = coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
let r = crate::pm3::gradients::compute_pm3_gradient(elements, &positions)?;
let energy_kcal = r.energy * EV_TO_KCAL_MOL;
for (a, g) in r.gradients.iter().enumerate() {
for d in 0..3 {
grad[a * 3 + d] = g[d] * EV_TO_KCAL_MOL;
}
}
Ok(energy_kcal)
}
NebBackend::Xtb => {
let positions: Vec<[f64; 3]> = coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
let r = crate::xtb::gradients::compute_xtb_gradient(elements, &positions)?;
let energy_kcal = r.energy * EV_TO_KCAL_MOL;
for (a, g) in r.gradients.iter().enumerate() {
for d in 0..3 {
grad[a * 3 + d] = g[d] * EV_TO_KCAL_MOL;
}
}
Ok(energy_kcal)
}
NebBackend::Gfn1 | NebBackend::Gfn2 | NebBackend::Hf3c => {
neb_numerical_gradient(backend, elements, coords, grad)
}
}
}
pub fn compute_simplified_neb_path_configurable(
smiles: &str,
start_coords: &[f64],
end_coords: &[f64],
n_images: usize,
n_iter: usize,
spring_k: f64,
step_size: f64,
method: &str,
) -> Result<NebPathResult, String> {
let backend = NebBackend::from_method(method)?;
if n_images < 2 {
return Err("n_images must be >= 2".to_string());
}
if n_iter == 0 {
return Err("n_iter must be > 0".to_string());
}
if step_size <= 0.0 {
return Err("step_size must be > 0".to_string());
}
let mol = crate::graph::Molecule::from_smiles(smiles)?;
let n_atoms = mol.graph.node_count();
let n_xyz = n_atoms * 3;
if start_coords.len() != n_xyz || end_coords.len() != n_xyz {
return Err(format!(
"start/end coords must each have length {} (3 * n_atoms)",
n_xyz
));
}
let elements: Vec<u8> = (0..n_atoms)
.map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
.collect();
let mut images = vec![vec![0.0f64; n_xyz]; n_images];
for (img_idx, img) in images.iter_mut().enumerate() {
let t = img_idx as f64 / (n_images - 1) as f64;
for k in 0..n_xyz {
img[k] = (1.0 - t) * start_coords[k] + t * end_coords[k];
}
}
for _ in 0..n_iter {
let prev = images.clone();
for i in 1..(n_images - 1) {
let mut grad = vec![0.0f64; n_xyz];
let _ = neb_energy_and_gradient(backend, smiles, &elements, &mol, &prev[i], &mut grad)?;
for k in 0..n_xyz {
let spring_force = spring_k * (prev[i + 1][k] - 2.0 * prev[i][k] + prev[i - 1][k]);
let total_force = -grad[k] + spring_force;
images[i][k] = prev[i][k] + step_size * total_force;
}
}
}
let mut out_images = Vec::with_capacity(n_images);
for (i, coords) in images.into_iter().enumerate() {
let mut grad = vec![0.0f64; n_xyz];
let e = neb_energy_and_gradient(backend, smiles, &elements, &mol, &coords, &mut grad)?;
out_images.push(NebImage {
index: i,
coords,
potential_energy_kcal_mol: e,
});
}
Ok(NebPathResult {
images: out_images,
notes: vec![
format!(
"Simplified NEB ({}) with spring-coupled gradient relaxation on {} internal images.",
backend.as_str(),
n_images.saturating_sub(2),
),
"Low-cost exploratory path; not a full climbing-image / tangent-projected NEB."
.to_string(),
],
})
}
pub fn neb_backend_energy_kcal(method: &str, smiles: &str, coords: &[f64]) -> Result<f64, String> {
let backend = NebBackend::from_method(method)?;
let mol = crate::graph::Molecule::from_smiles(smiles)?;
let n_atoms = mol.graph.node_count();
let n_xyz = n_atoms * 3;
if coords.len() != n_xyz {
return Err(format!(
"coords length {} != 3 * atoms {}",
coords.len(),
n_atoms
));
}
let elements: Vec<u8> = (0..n_atoms)
.map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
.collect();
let mut grad = vec![0.0f64; n_xyz];
neb_energy_and_gradient(backend, smiles, &elements, &mol, coords, &mut grad)
}
pub fn neb_backend_energy_and_gradient(
method: &str,
smiles: &str,
coords: &[f64],
) -> Result<(f64, Vec<f64>), String> {
let backend = NebBackend::from_method(method)?;
let mol = crate::graph::Molecule::from_smiles(smiles)?;
let n_atoms = mol.graph.node_count();
let n_xyz = n_atoms * 3;
if coords.len() != n_xyz {
return Err(format!(
"coords length {} != 3 * atoms {}",
coords.len(),
n_atoms
));
}
let elements: Vec<u8> = (0..n_atoms)
.map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
.collect();
let mut grad = vec![0.0f64; n_xyz];
let energy = neb_energy_and_gradient(backend, smiles, &elements, &mol, coords, &mut grad)?;
Ok((energy, grad))
}
pub fn compute_backend_energy_and_gradients(
backend: MdBackend,
smiles: &str,
elements: &[u8],
coords: &[f64],
grad: &mut [f64],
) -> Result<f64, String> {
match backend {
MdBackend::Uff => {
let mol = crate::graph::Molecule::from_smiles(smiles)?;
let ff = crate::forcefield::builder::build_uff_force_field(&mol);
let e = ff.compute_system_energy_and_gradients(coords, grad);
Ok(e)
}
MdBackend::Pm3 => {
let positions: Vec<[f64; 3]> = coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
let grad_result = crate::pm3::gradients::compute_pm3_gradient(elements, &positions)?;
let energy_kcal = grad_result.energy * 23.0605;
for (a, g) in grad_result.gradients.iter().enumerate() {
for d in 0..3 {
grad[a * 3 + d] = g[d] * 23.0605;
}
}
Ok(energy_kcal)
}
MdBackend::Xtb => {
let positions: Vec<[f64; 3]> = coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
let grad_result = crate::xtb::gradients::compute_xtb_gradient(elements, &positions)?;
let energy_kcal = grad_result.energy * 23.0605;
for (a, g) in grad_result.gradients.iter().enumerate() {
for d in 0..3 {
grad[a * 3 + d] = g[d] * 23.0605;
}
}
Ok(energy_kcal)
}
}
}
pub fn simulate_velocity_verlet(
smiles: &str,
coords: &[f64],
elements: &[u8],
n_steps: usize,
dt_fs: f64,
seed: u64,
target_temp_and_tau: Option<(f64, f64)>,
backend: MdBackend,
) -> Result<MdTrajectory, String> {
if n_steps == 0 {
return Err("n_steps must be > 0".to_string());
}
let n_atoms = elements.len();
if coords.len() != n_atoms * 3 {
return Err(format!(
"coords length {} != 3*atoms {}",
coords.len(),
n_atoms
));
}
let masses_amu: Vec<f64> = elements.iter().map(|&z| atomic_mass_amu(z)).collect();
let mut x = coords.to_vec();
let mut grad = vec![0.0f64; n_atoms * 3];
let mut potential =
compute_backend_energy_and_gradients(backend, smiles, elements, &x, &mut grad)?;
let mut rng = StdRng::seed_from_u64(seed);
let mut v = vec![0.0f64; n_atoms * 3];
if let Some((target_temp_k, _)) = target_temp_and_tau {
for i in 0..n_atoms {
let sigma = ((R_GAS_KCAL_MOLK * target_temp_k)
/ (masses_amu[i] * AMU_ANGFS2_TO_KCAL_MOL))
.sqrt();
v[3 * i] = sigma * sample_standard_normal(&mut rng);
v[3 * i + 1] = sigma * sample_standard_normal(&mut rng);
v[3 * i + 2] = sigma * sample_standard_normal(&mut rng);
}
}
let (ke0, t0) = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
let mut frames = vec![MdFrame {
step: 0,
time_fs: 0.0,
coords: x.clone(),
potential_energy_kcal_mol: potential,
kinetic_energy_kcal_mol: ke0,
temperature_k: t0,
}];
for step in 1..=n_steps {
let mut a = vec![0.0f64; n_atoms * 3];
for i in 0..n_atoms {
let m = masses_amu[i];
for k in 0..3 {
a[3 * i + k] = -grad[3 * i + k] / (m * AMU_ANGFS2_TO_KCAL_MOL);
}
}
for i in 0..(n_atoms * 3) {
x[i] += v[i] * dt_fs + 0.5 * a[i] * dt_fs * dt_fs;
}
potential = compute_backend_energy_and_gradients(backend, smiles, elements, &x, &mut grad)?;
let mut a_new = vec![0.0f64; n_atoms * 3];
for i in 0..n_atoms {
let m = masses_amu[i];
for k in 0..3 {
a_new[3 * i + k] = -grad[3 * i + k] / (m * AMU_ANGFS2_TO_KCAL_MOL);
}
}
for i in 0..(n_atoms * 3) {
v[i] += 0.5 * (a[i] + a_new[i]) * dt_fs;
}
let (mut ke, mut temp_k) = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
if let Some((target_temp_k, tau_fs)) = target_temp_and_tau {
let tau = tau_fs.max(1e-6);
let lambda = (1.0 + (dt_fs / tau) * (target_temp_k / temp_k.max(1e-6) - 1.0)).sqrt();
let lambda = lambda.clamp(0.5, 2.0);
for vi in &mut v {
*vi *= lambda;
}
let kt = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
ke = kt.0;
temp_k = kt.1;
}
if !x.iter().all(|v| v.is_finite()) || !potential.is_finite() {
return Err(format!("MD diverged at step {}", step));
}
frames.push(MdFrame {
step,
time_fs: step as f64 * dt_fs,
coords: x.clone(),
potential_energy_kcal_mol: potential,
kinetic_energy_kcal_mol: ke,
temperature_k: temp_k,
});
}
let energy_drift_percent = if target_temp_and_tau.is_none() {
let e0 = frames[0].potential_energy_kcal_mol + frames[0].kinetic_energy_kcal_mol;
let ef = frames
.last()
.map(|f| f.potential_energy_kcal_mol + f.kinetic_energy_kcal_mol)
.unwrap_or(e0);
if e0.abs() > 1e-10 {
Some((ef - e0) / e0.abs() * 100.0)
} else {
None
}
} else {
None
};
Ok(MdTrajectory {
frames,
dt_fs,
notes: vec![format!("Velocity Verlet with {:?} backend.", backend)],
energy_drift_percent,
})
}
pub fn simulate_nose_hoover(
smiles: &str,
coords: &[f64],
elements: &[u8],
n_steps: usize,
dt_fs: f64,
target_temp_k: f64,
thermostat_mass: f64,
chain_length: usize,
seed: u64,
backend: MdBackend,
) -> Result<MdTrajectory, String> {
if n_steps == 0 {
return Err("n_steps must be > 0".to_string());
}
let n_atoms = elements.len();
if coords.len() != n_atoms * 3 {
return Err("coords length mismatch".to_string());
}
let masses_amu: Vec<f64> = elements.iter().map(|&z| atomic_mass_amu(z)).collect();
let dof = (3 * n_atoms).saturating_sub(6).max(1) as f64;
let target_ke = 0.5 * dof * R_GAS_KCAL_MOLK * target_temp_k;
let mut x = coords.to_vec();
let mut grad = vec![0.0f64; n_atoms * 3];
let mut potential =
compute_backend_energy_and_gradients(backend, smiles, elements, &x, &mut grad)?;
let mut rng = StdRng::seed_from_u64(seed);
let mut v = vec![0.0f64; n_atoms * 3];
for i in 0..n_atoms {
let sigma =
((R_GAS_KCAL_MOLK * target_temp_k) / (masses_amu[i] * AMU_ANGFS2_TO_KCAL_MOL)).sqrt();
v[3 * i] = sigma * sample_standard_normal(&mut rng);
v[3 * i + 1] = sigma * sample_standard_normal(&mut rng);
v[3 * i + 2] = sigma * sample_standard_normal(&mut rng);
}
let chain_len = chain_length.max(1);
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);
let mut frames = vec![MdFrame {
step: 0,
time_fs: 0.0,
coords: x.clone(),
potential_energy_kcal_mol: potential,
kinetic_energy_kcal_mol: ke0,
temperature_k: t0,
}];
let dt2 = dt_fs * 0.5;
let dt4 = dt_fs * 0.25;
for step in 1..=n_steps {
let (ke, _) = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
let mut g_xi = vec![0.0f64; chain_len];
g_xi[0] = (2.0 * ke - 2.0 * target_ke) / q[0];
for j in 1..chain_len {
g_xi[j] =
(q[j - 1] * v_xi[j - 1] * v_xi[j - 1] - R_GAS_KCAL_MOLK * target_temp_k) / q[j];
}
if chain_len > 1 {
v_xi[chain_len - 1] += g_xi[chain_len - 1] * dt4;
}
for j in (0..chain_len.saturating_sub(1)).rev() {
let exp_factor = (-v_xi[j + 1] * dt4).exp();
v_xi[j] = v_xi[j] * exp_factor + g_xi[j] * dt4;
}
let scale = (-v_xi[0] * dt2).exp();
for vi in v.iter_mut() {
*vi *= scale;
}
for j in 0..chain_len {
xi[j] += v_xi[j] * dt2;
}
let (ke_scaled, _) = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
g_xi[0] = (2.0 * ke_scaled - 2.0 * target_ke) / q[0];
for j in 0..chain_len.saturating_sub(1) {
let exp_factor = (-v_xi[j + 1] * dt4).exp();
v_xi[j] = v_xi[j] * exp_factor + g_xi[j] * dt4;
}
if chain_len > 1 {
g_xi[chain_len - 1] = (q[chain_len - 2] * v_xi[chain_len - 2] * v_xi[chain_len - 2]
- R_GAS_KCAL_MOLK * target_temp_k)
/ q[chain_len - 1];
v_xi[chain_len - 1] += g_xi[chain_len - 1] * dt4;
}
for i in 0..n_atoms {
let m = masses_amu[i];
for k in 0..3 {
v[3 * i + k] -= 0.5 * dt_fs * grad[3 * i + k] / (m * AMU_ANGFS2_TO_KCAL_MOL);
}
}
for i in 0..(n_atoms * 3) {
x[i] += v[i] * dt_fs;
}
potential = compute_backend_energy_and_gradients(backend, smiles, elements, &x, &mut grad)?;
for i in 0..n_atoms {
let m = masses_amu[i];
for k in 0..3 {
v[3 * i + k] -= 0.5 * dt_fs * grad[3 * i + k] / (m * AMU_ANGFS2_TO_KCAL_MOL);
}
}
let (ke_final, temp_k) = kinetic_energy_and_temperature(&v, &masses_amu, n_atoms);
if !x.iter().all(|v| v.is_finite()) || !potential.is_finite() {
return Err(format!("NH-MD diverged at step {}", step));
}
frames.push(MdFrame {
step,
time_fs: step as f64 * dt_fs,
coords: x.clone(),
potential_energy_kcal_mol: potential,
kinetic_energy_kcal_mol: ke_final,
temperature_k: temp_k,
});
}
let temps: Vec<f64> = frames
.iter()
.skip(frames.len() / 5)
.map(|f| f.temperature_k)
.collect();
let avg_temp = temps.iter().sum::<f64>() / temps.len() as f64;
let temp_variance =
temps.iter().map(|t| (t - avg_temp).powi(2)).sum::<f64>() / temps.len() as f64;
Ok(MdTrajectory {
frames,
dt_fs,
notes: vec![
format!(
"Nosé-Hoover chain thermostat (chain={}) with {:?} backend.",
chain_len, backend
),
format!(
"Target T = {:.1} K, avg T = {:.1} K, σ(T) = {:.1} K",
target_temp_k,
avg_temp,
temp_variance.sqrt()
),
],
energy_drift_percent: None,
})
}
pub fn trajectory_to_xyz(trajectory: &MdTrajectory, elements: &[u8]) -> String {
let n_atoms = elements.len();
let mut output = String::new();
for frame in &trajectory.frames {
output.push_str(&format!("{}\n", n_atoms));
output.push_str(&format!(
"step={} t={:.1}fs E_pot={:.4} E_kin={:.4} T={:.1}K\n",
frame.step,
frame.time_fs,
frame.potential_energy_kcal_mol,
frame.kinetic_energy_kcal_mol,
frame.temperature_k,
));
for i in 0..n_atoms {
let sym = element_symbol(elements[i]);
output.push_str(&format!(
"{} {:.6} {:.6} {:.6}\n",
sym,
frame.coords[3 * i],
frame.coords[3 * i + 1],
frame.coords[3 * i + 2],
));
}
}
output
}
fn element_symbol(z: u8) -> &'static str {
match z {
1 => "H",
2 => "He",
3 => "Li",
4 => "Be",
5 => "B",
6 => "C",
7 => "N",
8 => "O",
9 => "F",
10 => "Ne",
11 => "Na",
12 => "Mg",
13 => "Al",
14 => "Si",
15 => "P",
16 => "S",
17 => "Cl",
18 => "Ar",
19 => "K",
20 => "Ca",
22 => "Ti",
24 => "Cr",
25 => "Mn",
26 => "Fe",
27 => "Co",
28 => "Ni",
29 => "Cu",
30 => "Zn",
35 => "Br",
44 => "Ru",
46 => "Pd",
47 => "Ag",
53 => "I",
78 => "Pt",
79 => "Au",
_ => "X",
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReactionDynamicsConfig {
pub n_neb_images: usize,
pub neb_iterations: usize,
pub spring_k: f64,
pub step_size: f64,
pub n_approach_frames: usize,
pub n_departure_frames: usize,
pub far_distance: f64,
pub reactive_distance: f64,
pub seed: u64,
}
impl Default for ReactionDynamicsConfig {
fn default() -> Self {
Self {
n_neb_images: 30,
neb_iterations: 100,
spring_k: 0.1,
step_size: 0.01,
n_approach_frames: 15,
n_departure_frames: 15,
far_distance: 8.0,
reactive_distance: 2.0,
seed: 42,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReactionDynamicsFrame {
pub index: usize,
pub coords: Vec<f64>,
pub energy_kcal_mol: f64,
pub phase: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReactionDynamicsResult {
pub frames: Vec<ReactionDynamicsFrame>,
pub elements: Vec<u8>,
pub ts_frame_index: usize,
pub activation_energy_kcal_mol: f64,
pub reaction_energy_kcal_mol: f64,
pub method: String,
pub n_atoms: usize,
pub notes: Vec<String>,
}
pub fn com_flat(coords: &[f64]) -> [f64; 3] {
let n = coords.len() / 3;
if n == 0 {
return [0.0; 3];
}
let mut cx = 0.0;
let mut cy = 0.0;
let mut cz = 0.0;
for i in 0..n {
cx += coords[i * 3];
cy += coords[i * 3 + 1];
cz += coords[i * 3 + 2];
}
let nf = n as f64;
[cx / nf, cy / nf, cz / nf]
}
pub fn centre_at_origin(coords: &mut [f64]) {
let [cx, cy, cz] = com_flat(coords);
for i in (0..coords.len()).step_by(3) {
coords[i] -= cx;
coords[i + 1] -= cy;
coords[i + 2] -= cz;
}
}
fn rotate_to_align(coords: &mut [f64], from: [f64; 3], to: [f64; 3]) {
let fl = (from[0] * from[0] + from[1] * from[1] + from[2] * from[2]).sqrt();
let tl = (to[0] * to[0] + to[1] * to[1] + to[2] * to[2]).sqrt();
if fl < 1e-10 || tl < 1e-10 {
return;
}
let fx = from[0] / fl;
let fy = from[1] / fl;
let fz = from[2] / fl;
let tx = to[0] / tl;
let ty = to[1] / tl;
let tz = to[2] / tl;
let kx = fy * tz - fz * ty;
let ky = fz * tx - fx * tz;
let kz = fx * ty - fy * tx;
let sin_a = (kx * kx + ky * ky + kz * kz).sqrt();
let cos_a = fx * tx + fy * ty + fz * tz;
if sin_a < 1e-10 {
if cos_a > 0.0 {
return; }
for i in (0..coords.len()).step_by(3) {
coords[i] = -coords[i];
}
return;
}
let nkx = kx / sin_a;
let nky = ky / sin_a;
let nkz = kz / sin_a;
let c = cos_a;
let s = sin_a;
let t1 = 1.0 - c;
for i in (0..coords.len()).step_by(3) {
let x = coords[i];
let y = coords[i + 1];
let z = coords[i + 2];
let dot = nkx * x + nky * y + nkz * z;
coords[i] = x * c + (nky * z - nkz * y) * s + nkx * dot * t1;
coords[i + 1] = y * c + (nkz * x - nkx * z) * s + nky * dot * t1;
coords[i + 2] = z * c + (nkx * y - nky * x) * s + nkz * dot * t1;
}
}
fn build_reaction_complex(
conformers: &[crate::ConformerResult],
reactive_dist: f64,
) -> (Vec<f64>, Vec<u8>) {
if conformers.is_empty() {
return (vec![], vec![]);
}
let mut mols: Vec<(Vec<f64>, Vec<u8>)> = conformers
.iter()
.map(|c| {
let mut coords = c.coords.clone();
centre_at_origin(&mut coords);
(coords, c.elements.clone())
})
.collect();
if mols.len() == 1 {
let (mut coords, elems) = mols.remove(0);
centre_at_origin(&mut coords);
return (coords, elems);
}
let n0 = mols[0].1.len();
let n1 = mols[1].1.len();
let mut best_i = 0usize;
let mut best_j = 0usize;
let mut best_d2 = f64::INFINITY;
let ref_off = 4.0;
for i in 0..n0 {
let xi = mols[0].0[i * 3];
let yi = mols[0].0[i * 3 + 1];
let zi = mols[0].0[i * 3 + 2];
for j in 0..n1 {
let dx = mols[1].0[j * 3] + ref_off - xi;
let dy = mols[1].0[j * 3 + 1] - yi;
let dz = mols[1].0[j * 3 + 2] - zi;
let d2 = dx * dx + dy * dy + dz * dz;
if d2 < best_d2 {
best_d2 = d2;
best_i = i;
best_j = j;
}
}
}
{
let rx = mols[0].0[best_i * 3];
let ry = mols[0].0[best_i * 3 + 1];
let rz = mols[0].0[best_i * 3 + 2];
let rl = (rx * rx + ry * ry + rz * rz).sqrt();
if rl > 0.05 {
rotate_to_align(&mut mols[0].0, [rx / rl, ry / rl, rz / rl], [1.0, 0.0, 0.0]);
}
}
{
let rx = mols[1].0[best_j * 3];
let ry = mols[1].0[best_j * 3 + 1];
let rz = mols[1].0[best_j * 3 + 2];
let rl = (rx * rx + ry * ry + rz * rz).sqrt();
if rl > 0.05 {
rotate_to_align(
&mut mols[1].0,
[rx / rl, ry / rl, rz / rl],
[-1.0, 0.0, 0.0],
);
}
}
let ra_x = mols[0].0[best_i * 3];
let rb_x = mols[1].0[best_j * 3];
let offset_x = ra_x - rb_x + reactive_dist;
let total_atoms: usize = mols.iter().map(|(_, e)| e.len()).sum();
let mut all_coords = Vec::with_capacity(total_atoms * 3);
let mut all_elements = Vec::with_capacity(total_atoms);
all_coords.extend_from_slice(&mols[0].0);
all_elements.extend_from_slice(&mols[0].1);
for k in 0..n1 {
all_coords.push(mols[1].0[k * 3] + offset_x);
all_coords.push(mols[1].0[k * 3 + 1]);
all_coords.push(mols[1].0[k * 3 + 2]);
}
all_elements.extend_from_slice(&mols[1].1);
let mut extra = offset_x + 4.0;
for mol in mols.iter().skip(2) {
for k in 0..mol.1.len() {
all_coords.push(mol.0[k * 3] + extra);
all_coords.push(mol.0[k * 3 + 1]);
all_coords.push(mol.0[k * 3 + 2]);
}
all_elements.extend_from_slice(&mol.1);
extra += 4.0;
}
centre_at_origin(&mut all_coords);
(all_coords, all_elements)
}
pub fn map_atoms_greedy(
r_elements: &[u8],
r_coords: &[f64],
p_elements: &[u8],
p_coords: &[f64],
) -> Result<Vec<usize>, String> {
let n = r_elements.len();
if n != p_elements.len() {
return Err(format!(
"Atom count mismatch: {} reactant atoms vs {} product atoms",
n,
p_elements.len(),
));
}
let mut r_by_elem: std::collections::HashMap<u8, Vec<usize>> = std::collections::HashMap::new();
let mut p_by_elem: std::collections::HashMap<u8, Vec<usize>> = std::collections::HashMap::new();
for i in 0..n {
r_by_elem.entry(r_elements[i]).or_default().push(i);
p_by_elem.entry(p_elements[i]).or_default().push(i);
}
let mut mapping = vec![0usize; n];
let mut used_p = vec![false; n];
for (&elem, r_indices) in &r_by_elem {
let p_indices = p_by_elem
.get(&elem)
.ok_or_else(|| format!("Element Z={} present in reactants but not products", elem,))?;
if r_indices.len() != p_indices.len() {
return Err(format!(
"Element Z={}: {} in reactants vs {} in products",
elem,
r_indices.len(),
p_indices.len(),
));
}
let mut pairs: Vec<(usize, usize, f64)> = Vec::new();
for &ri in r_indices {
for &pi in p_indices {
let dx = r_coords[ri * 3] - p_coords[pi * 3];
let dy = r_coords[ri * 3 + 1] - p_coords[pi * 3 + 1];
let dz = r_coords[ri * 3 + 2] - p_coords[pi * 3 + 2];
pairs.push((ri, pi, dx * dx + dy * dy + dz * dz));
}
}
pairs.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal));
let mut used_r = vec![false; n];
for (ri, pi, _) in pairs {
if used_r[ri] || used_p[pi] {
continue;
}
mapping[ri] = pi;
used_r[ri] = true;
used_p[pi] = true;
}
}
Ok(mapping)
}
pub fn reorder_coords(coords: &[f64], mapping: &[usize]) -> Vec<f64> {
let mut out = vec![0.0; coords.len()];
for (i, &j) in mapping.iter().enumerate() {
out[i * 3] = coords[j * 3];
out[i * 3 + 1] = coords[j * 3 + 1];
out[i * 3 + 2] = coords[j * 3 + 2];
}
out
}
pub fn slide_molecules(
complex_coords: &[f64],
mol_ranges: &[(usize, usize)],
alpha: f64,
far_dist: f64,
) -> Vec<f64> {
let mut out = complex_coords.to_vec();
let [gcx, gcy, gcz] = com_flat(complex_coords);
for &(start, end) in mol_ranges {
let n = end - start;
if n == 0 {
continue;
}
let mut mx = 0.0;
let mut my = 0.0;
let mut mz = 0.0;
for a in start..end {
mx += complex_coords[a * 3];
my += complex_coords[a * 3 + 1];
mz += complex_coords[a * 3 + 2];
}
let nf = n as f64;
mx /= nf;
my /= nf;
mz /= nf;
let dx = mx - gcx;
let dy = my - gcy;
let dz = mz - gcz;
let d = (dx * dx + dy * dy + dz * dz).sqrt();
if d < 0.01 {
continue;
}
let target = d + (far_dist - d) * (1.0 - alpha);
let shift = target - d;
let nx = dx / d;
let ny = dy / d;
let nz = dz / d;
for a in start..end {
out[a * 3] += nx * shift;
out[a * 3 + 1] += ny * shift;
out[a * 3 + 2] += nz * shift;
}
}
out
}
fn build_product_guided_reactant_complex(
r_confs: &[crate::ConformerResult],
p_reordered_coords: &[f64],
) -> Vec<f64> {
let n_total: usize = r_confs.iter().map(|c| c.num_atoms).sum();
let mut all_coords = vec![0.0f64; n_total * 3];
let mut atom_off = 0usize;
for conf in r_confs {
let n = conf.num_atoms;
let p_frag: Vec<f64> = (atom_off..atom_off + n)
.flat_map(|a| {
[
p_reordered_coords[a * 3],
p_reordered_coords[a * 3 + 1],
p_reordered_coords[a * 3 + 2],
]
})
.collect();
let p_com = com_flat(&p_frag);
let mut r_frag = conf.coords.clone();
centre_at_origin(&mut r_frag);
if n >= 2 {
let aligned = crate::alignment::kabsch::align_coordinates(&r_frag, &p_frag);
let ac = com_flat(&aligned.aligned_coords);
for a in 0..n {
all_coords[(atom_off + a) * 3] = aligned.aligned_coords[a * 3] - ac[0] + p_com[0];
all_coords[(atom_off + a) * 3 + 1] =
aligned.aligned_coords[a * 3 + 1] - ac[1] + p_com[1];
all_coords[(atom_off + a) * 3 + 2] =
aligned.aligned_coords[a * 3 + 2] - ac[2] + p_com[2];
}
} else {
for a in 0..n {
all_coords[(atom_off + a) * 3] = r_frag[a * 3] + p_com[0];
all_coords[(atom_off + a) * 3 + 1] = r_frag[a * 3 + 1] + p_com[1];
all_coords[(atom_off + a) * 3 + 2] = r_frag[a * 3 + 2] + p_com[2];
}
}
atom_off += n;
}
centre_at_origin(&mut all_coords);
all_coords
}
pub fn compute_reaction_dynamics(
reactant_smiles: &[&str],
product_smiles: &[&str],
method: &str,
config: &ReactionDynamicsConfig,
) -> Result<ReactionDynamicsResult, String> {
if reactant_smiles.is_empty() {
return Err("At least one reactant SMILES is required".into());
}
if product_smiles.is_empty() {
return Err("At least one product SMILES is required".into());
}
let backend = NebBackend::from_method(method)?;
let r_confs: Vec<crate::ConformerResult> = reactant_smiles
.iter()
.map(|s| crate::embed(s, config.seed))
.collect();
for (i, c) in r_confs.iter().enumerate() {
if let Some(ref e) = c.error {
return Err(format!(
"Failed to embed reactant '{}': {}",
reactant_smiles[i], e
));
}
}
let p_confs: Vec<crate::ConformerResult> = product_smiles
.iter()
.map(|s| crate::embed(s, config.seed))
.collect();
for (i, c) in p_confs.iter().enumerate() {
if let Some(ref e) = c.error {
return Err(format!(
"Failed to embed product '{}': {}",
product_smiles[i], e
));
}
}
let r_total: usize = r_confs.iter().map(|c| c.num_atoms).sum();
let p_total: usize = p_confs.iter().map(|c| c.num_atoms).sum();
if r_total != p_total {
return Err(format!(
"Atom count mismatch: {} atoms in reactants vs {} in products — \
atoms must be conserved",
r_total, p_total,
));
}
let (p_coords, p_elements) = build_reaction_complex(&p_confs, config.reactive_distance);
let r_elements: Vec<u8> = r_confs
.iter()
.flat_map(|c| c.elements.iter().copied())
.collect();
let (r_coords_tmp, _) = build_reaction_complex(&r_confs, config.reactive_distance);
let mapping = map_atoms_greedy(&r_elements, &r_coords_tmp, &p_elements, &p_coords)?;
let p_reordered = reorder_coords(&p_coords, &mapping);
let r_coords = build_product_guided_reactant_complex(&r_confs, &p_reordered);
let combined_smiles = reactant_smiles.join(".");
let neb = compute_simplified_neb_path_configurable(
&combined_smiles,
&r_coords,
&p_reordered,
config.n_neb_images,
config.neb_iterations,
config.spring_k,
config.step_size,
method,
)?;
let r_mol_ranges: Vec<(usize, usize)> = {
let mut ranges = Vec::new();
let mut off = 0usize;
for c in &r_confs {
ranges.push((off, off + c.num_atoms));
off += c.num_atoms;
}
ranges
};
let mut frames = Vec::new();
let mol = crate::graph::Molecule::from_smiles(&combined_smiles)?;
let n_xyz = r_total * 3;
let na = config.n_approach_frames;
for i in 0..na {
let alpha = if na > 1 {
i as f64 / (na - 1) as f64
} else {
1.0
};
let coords = slide_molecules(&r_coords, &r_mol_ranges, alpha, config.far_distance);
let mut grad = vec![0.0; n_xyz];
let energy = neb_energy_and_gradient(
backend,
&combined_smiles,
&r_elements,
&mol,
&coords,
&mut grad,
)
.unwrap_or(0.0);
frames.push(ReactionDynamicsFrame {
index: frames.len(),
coords,
energy_kcal_mol: energy,
phase: "approach".into(),
});
}
for img in &neb.images {
frames.push(ReactionDynamicsFrame {
index: frames.len(),
coords: img.coords.clone(),
energy_kcal_mol: img.potential_energy_kcal_mol,
phase: "reaction".into(),
});
}
let nd = config.n_departure_frames;
let single_product = p_confs.len() == 1;
let p_mol_ranges: Vec<(usize, usize)> = if single_product {
vec![(0, r_total)]
} else {
let p_mol_assign: Vec<usize> = {
let mut assign = vec![0usize; p_total];
let mut off = 0usize;
for (m, c) in p_confs.iter().enumerate() {
for a in off..off + c.num_atoms {
assign[a] = m;
}
off += c.num_atoms;
}
mapping.iter().map(|&pi| assign[pi]).collect()
};
let n_mols = p_confs.len();
let mut ranges = Vec::with_capacity(n_mols);
for m in 0..n_mols {
let atoms: Vec<usize> = p_mol_assign
.iter()
.enumerate()
.filter(|(_, &a)| a == m)
.map(|(i, _)| i)
.collect();
if let (Some(&lo), Some(&hi)) = (atoms.first(), atoms.last()) {
ranges.push((lo, hi + 1));
}
}
ranges
};
for i in 0..nd {
let alpha = if nd > 1 {
1.0 - i as f64 / (nd - 1) as f64
} else {
0.0
};
let coords = if single_product {
p_reordered.clone()
} else {
slide_molecules(&p_reordered, &p_mol_ranges, alpha, config.far_distance)
};
let mut grad = vec![0.0; n_xyz];
let energy = neb_energy_and_gradient(
backend,
&combined_smiles,
&r_elements,
&mol,
&coords,
&mut grad,
)
.unwrap_or(0.0);
frames.push(ReactionDynamicsFrame {
index: frames.len(),
coords,
energy_kcal_mol: energy,
phase: "departure".into(),
});
}
let ts_idx = frames
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| {
a.energy_kcal_mol
.partial_cmp(&b.energy_kcal_mol)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(i, _)| i)
.unwrap_or(0);
let e_first = frames.first().map(|f| f.energy_kcal_mol).unwrap_or(0.0);
let e_last = frames.last().map(|f| f.energy_kcal_mol).unwrap_or(0.0);
let e_ts = frames[ts_idx].energy_kcal_mol;
let mut notes = neb.notes;
notes.push(format!(
"Full reaction path: {} approach + {} NEB + {} departure = {} total frames.",
na,
neb.images.len(),
nd,
frames.len(),
));
notes.push(
"Reactant complex oriented using product geometry for chemically meaningful approach."
.to_string(),
);
Ok(ReactionDynamicsResult {
frames,
elements: r_elements,
ts_frame_index: ts_idx,
activation_energy_kcal_mol: e_ts - e_first,
reaction_energy_kcal_mol: e_last - e_first,
method: method.to_string(),
n_atoms: r_total,
notes,
})
}