use std::f64;
use chematic_chem::gasteiger_charges;
use chematic_core::Molecule;
use chematic_ff::{assign_dreiding_types, dreiding_vdw};
use crate::coords::{Coords3D, Point3};
const K_BOLTZMANN: f64 = 0.0019872041; const K_COULOMB: f64 = 332.0637;
#[derive(Clone, Debug)]
pub enum Thermostat {
None,
Berendsen { tau_fs: f64 },
}
#[derive(Clone, Debug)]
pub struct MDConfig {
pub timestep_fs: f64,
pub steps: usize,
pub temperature_k: f64,
pub thermostat: Thermostat,
pub save_every: usize,
pub coulomb: bool,
}
impl Default for MDConfig {
fn default() -> Self {
Self {
timestep_fs: 1.0,
steps: 100,
temperature_k: 300.0,
thermostat: Thermostat::Berendsen { tau_fs: 100.0 },
save_every: 10,
coulomb: true,
}
}
}
#[derive(Clone, Debug)]
pub struct MDFrame {
pub step: usize,
pub coords: Coords3D,
pub potential_energy: f64,
pub kinetic_energy: f64,
pub temperature_k: f64,
}
#[derive(Clone, Debug)]
pub struct MDTrajectory {
pub frames: Vec<MDFrame>,
}
pub fn run_md(mol: &Molecule, coords: Coords3D, config: &MDConfig) -> MDTrajectory {
if mol.atom_count() == 0 {
return MDTrajectory { frames: vec![] };
}
let dt = config.timestep_fs / 1000.0;
let masses = get_atom_masses(mol);
let charges = if config.coulomb {
gasteiger_charges(mol)
} else {
vec![0.0; mol.atom_count()]
};
let mut velocities = initialize_velocities(&masses, config.temperature_k);
let mut trajectory = MDTrajectory { frames: vec![] };
let mut current_coords = coords;
for step in 0..config.steps {
let forces = compute_forces(mol, ¤t_coords, &charges, config.coulomb);
for i in 0..mol.atom_count() {
let m = masses[i];
if m > 0.0 {
velocities[i].x += 0.5 * forces[i].x / m * dt;
velocities[i].y += 0.5 * forces[i].y / m * dt;
velocities[i].z += 0.5 * forces[i].z / m * dt;
}
}
for i in 0..mol.atom_count() {
let m = masses[i];
let mut p = current_coords.get(chematic_core::AtomIdx(i as u32));
p.x += velocities[i].x * dt + 0.5 * forces[i].x / m * dt * dt;
p.y += velocities[i].y * dt + 0.5 * forces[i].y / m * dt * dt;
p.z += velocities[i].z * dt + 0.5 * forces[i].z / m * dt * dt;
current_coords.set(chematic_core::AtomIdx(i as u32), p);
}
let new_forces = compute_forces(mol, ¤t_coords, &charges, config.coulomb);
for i in 0..mol.atom_count() {
let m = masses[i];
if m > 0.0 {
velocities[i].x += 0.5 * new_forces[i].x / m * dt;
velocities[i].y += 0.5 * new_forces[i].y / m * dt;
velocities[i].z += 0.5 * new_forces[i].z / m * dt;
}
}
let current_pot_energy = total_energy(mol, ¤t_coords, &charges, config.coulomb);
let (_kinetic_energy, temperature) =
compute_kinetic_energy_and_temp(&velocities, &masses, config.temperature_k);
match config.thermostat {
Thermostat::None => {}
Thermostat::Berendsen { tau_fs } => {
let lambda = if temperature < 1e-6 {
1.0
} else {
let tau = tau_fs / 1000.0;
(1.0 + (dt / tau) * (config.temperature_k / temperature - 1.0)).sqrt()
};
for v in &mut velocities {
v.x *= lambda;
v.y *= lambda;
v.z *= lambda;
}
}
}
if (step + 1) % config.save_every == 0 {
let (ke, temp) =
compute_kinetic_energy_and_temp(&velocities, &masses, config.temperature_k);
trajectory.frames.push(MDFrame {
step: step + 1,
coords: current_coords.clone(),
potential_energy: current_pot_energy,
kinetic_energy: ke,
temperature_k: temp,
});
}
}
trajectory
}
fn get_atom_masses(mol: &Molecule) -> Vec<f64> {
mol.atoms()
.map(|(_, atom)| {
let z = atom.element.atomic_number();
match z {
1 => 1.008,
6 => 12.011,
7 => 14.007,
8 => 15.999,
9 => 18.998,
15 => 30.974,
16 => 32.06,
17 => 35.45,
35 => 79.904,
53 => 126.90,
_ => 12.0,
}
})
.collect()
}
fn initialize_velocities(masses: &[f64], temp_k: f64) -> Vec<Point3> {
const UNIT_CONVERSION: f64 = 0.01038;
(0..masses.len())
.map(|i| {
let m = masses[i];
if m > 1e-10 {
let sigma_sq = K_BOLTZMANN * temp_k * UNIT_CONVERSION / m;
let sigma = sigma_sq.sqrt();
Point3::new(
gaussian_random() * sigma,
gaussian_random() * sigma,
gaussian_random() * sigma,
)
} else {
Point3::zero()
}
})
.collect()
}
fn gaussian_random() -> f64 {
use std::f64::consts::PI;
let u1: f64 = (fastrand::f64()) + f64::EPSILON; let u2: f64 = fastrand::f64();
(-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
}
fn compute_forces(
mol: &Molecule,
coords: &Coords3D,
charges: &[f64],
coulomb: bool,
) -> Vec<Point3> {
let delta = 1e-4;
let mut forces = vec![Point3::zero(); mol.atom_count()];
fn energy_at_delta(
mol: &Molecule,
coords: &Coords3D,
idx: chematic_core::AtomIdx,
delta: f64,
axis: impl Fn(&mut Point3, f64),
charges: &[f64],
coulomb: bool,
) -> f64 {
let orig = coords.get(idx);
let mut p = orig;
axis(&mut p, delta);
let mut c = coords.clone();
c.set(idx, p);
let ep = total_energy(mol, &c, charges, coulomb);
let mut p = orig;
axis(&mut p, -delta);
c.set(idx, p);
let em = total_energy(mol, &c, charges, coulomb);
(ep - em) / (2.0 * delta)
}
for i in 0..mol.atom_count() {
let idx = chematic_core::AtomIdx(i as u32);
forces[i].x = energy_at_delta(mol, coords, idx, delta, |p, d| p.x += d, charges, coulomb);
forces[i].y = energy_at_delta(mol, coords, idx, delta, |p, d| p.y += d, charges, coulomb);
forces[i].z = energy_at_delta(mol, coords, idx, delta, |p, d| p.z += d, charges, coulomb);
}
forces
}
fn total_energy(mol: &Molecule, coords: &Coords3D, charges: &[f64], coulomb: bool) -> f64 {
bond_energy(mol, coords)
+ angle_energy(mol, coords)
+ vdw_energy(mol, coords)
+ if coulomb {
coulomb_energy(coords, charges)
} else {
0.0
}
}
fn bond_energy(mol: &Molecule, coords: &Coords3D) -> f64 {
let mut energy = 0.0;
let k = 700.0; for (_, bond) in mol.bonds() {
let a1 = bond.atom1;
let a2 = bond.atom2;
let r = coords.get(a1).distance(&coords.get(a2));
let sym1 = mol.atom(a1).element.symbol();
let sym2 = mol.atom(a2).element.symbol();
let ideal = ideal_bond_len(sym1, sym2, bond.order);
let delta = r - ideal;
energy += 0.5 * k * delta * delta;
}
energy
}
fn angle_energy(mol: &Molecule, coords: &Coords3D) -> f64 {
let mut energy = 0.0;
let k = 100.0;
for b_idx in 0..mol.atom_count() {
let b = chematic_core::AtomIdx(b_idx as u32);
let neighbors: Vec<_> = mol.neighbors(b).map(|(nb, _)| nb).collect();
if neighbors.len() < 2 {
continue;
}
for (i, &na) in neighbors.iter().enumerate() {
for &nb in &neighbors[i + 1..] {
let pa = coords.get(na);
let pb = coords.get(b);
let pc = coords.get(nb);
let v1 = Point3::new(pa.x - pb.x, pa.y - pb.y, pa.z - pb.z);
let v2 = Point3::new(pc.x - pb.x, pc.y - pb.y, pc.z - pb.z);
let angle = angle_from_vectors(&v1, &v2);
let sym = mol.atom(b).element.symbol();
let hyb = atom_hybridization(mol, b);
let ideal = ideal_angle_rad(sym, hyb);
let delta = angle - ideal;
energy += 0.5 * k * delta * delta;
}
}
}
energy
}
fn vdw_energy(mol: &Molecule, coords: &Coords3D) -> f64 {
let mut energy = 0.0;
let n = mol.atom_count();
let types = assign_dreiding_types(mol);
let mut excluded = std::collections::HashSet::new();
for (_, bond) in mol.bonds() {
let i = bond.atom1.0 as usize;
let j = bond.atom2.0 as usize;
excluded.insert((i.min(j), i.max(j)));
}
let bonds_vec: Vec<_> = mol.bonds().collect();
for (_, bond1) in &bonds_vec {
for (_, bond2) in &bonds_vec {
if bond1.atom2 == bond2.atom1 {
let i = bond1.atom1.0 as usize;
let k = bond2.atom2.0 as usize;
if i != k {
excluded.insert((i.min(k), i.max(k)));
}
}
}
}
for i in 0..n {
for j in (i + 1)..n {
if excluded.contains(&(i, j)) {
continue;
}
let ii = chematic_core::AtomIdx(i as u32);
let jj = chematic_core::AtomIdx(j as u32);
let pi = coords.get(ii);
let pj = coords.get(jj);
let r = pi.distance(&pj);
if r < 0.1 {
continue;
}
let (r_i, eps_i) = dreiding_vdw(types[i]);
let (r_j, eps_j) = dreiding_vdw(types[j]);
let sigma = 0.5 * (r_i + r_j);
let epsilon = (eps_i * eps_j).sqrt();
let sig_r = sigma / r;
let sig_r6 = sig_r * sig_r * sig_r * sig_r * sig_r * sig_r;
let sig_r12 = sig_r6 * sig_r6;
energy += epsilon * (sig_r12 - 2.0 * sig_r6);
}
}
energy
}
fn ideal_bond_len(sym1: &str, sym2: &str, order: chematic_core::BondOrder) -> f64 {
use chematic_core::BondOrder;
let (a, b) = if sym1 <= sym2 {
(sym1, sym2)
} else {
(sym2, sym1)
};
match (a, b, order) {
("C", "C", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.540,
("C", "C", BondOrder::Double) => 1.340,
("C", "C", BondOrder::Triple) => 1.204,
("C", "C", BondOrder::Aromatic) => 1.395,
("C", "H", _) => 1.090,
("C", "N", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.469,
("C", "N", BondOrder::Double) => 1.279,
("C", "N", BondOrder::Triple) => 1.158,
("C", "N", BondOrder::Aromatic) => 1.340,
("C", "O", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.427,
("C", "O", BondOrder::Double) => 1.217,
("C", "O", BondOrder::Aromatic) => 1.355,
("C", "S", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.819,
("H", "N", _) => 1.010,
("H", "O", _) => 0.960,
("N", "N", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.450,
("N", "N", BondOrder::Double) => 1.252,
("N", "N", BondOrder::Triple) => 1.098,
("N", "O", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.463,
("N", "O", BondOrder::Double) => 1.240,
("O", "O", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.450,
("O", "O", BondOrder::Double) => 1.207,
_ => 1.5,
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Hybridization {
SP,
SP2,
SP3,
}
fn atom_hybridization(mol: &Molecule, idx: chematic_core::AtomIdx) -> Hybridization {
use chematic_core::BondOrder;
if mol.atom(idx).aromatic {
return Hybridization::SP2;
}
let mut has_triple = false;
let mut has_double_or_aromatic = false;
for (_, bond_idx) in mol.neighbors(idx) {
match mol.bond(bond_idx).order {
BondOrder::Triple => has_triple = true,
BondOrder::Double | BondOrder::Aromatic => has_double_or_aromatic = true,
_ => {}
}
}
if has_triple {
Hybridization::SP
} else if has_double_or_aromatic {
Hybridization::SP2
} else {
Hybridization::SP3
}
}
fn ideal_angle_rad(sym: &str, hyb: Hybridization) -> f64 {
match hyb {
Hybridization::SP => 180.0_f64.to_radians(),
Hybridization::SP2 => 120.0_f64.to_radians(),
Hybridization::SP3 => match sym {
"O" | "Se" => 104.5_f64.to_radians(),
"N" => 107.0_f64.to_radians(),
"S" => 99.0_f64.to_radians(),
"P" => 93.0_f64.to_radians(),
_ => 109.47_f64.to_radians(),
},
}
}
fn angle_from_vectors(v1: &Point3, v2: &Point3) -> f64 {
let dot = v1.x * v2.x + v1.y * v2.y + v1.z * v2.z;
let mag1 = (v1.x * v1.x + v1.y * v1.y + v1.z * v1.z).sqrt();
let mag2 = (v2.x * v2.x + v2.y * v2.y + v2.z * v2.z).sqrt();
if mag1 > 1e-10 && mag2 > 1e-10 {
let cos_angle = (dot / (mag1 * mag2)).clamp(-1.0, 1.0);
cos_angle.acos()
} else {
0.0
}
}
fn coulomb_energy(coords: &Coords3D, charges: &[f64]) -> f64 {
let mut energy = 0.0;
let n = coords.atom_count();
for i in 0..n {
for j in (i + 1)..n {
let pi = coords.get(chematic_core::AtomIdx(i as u32));
let pj = coords.get(chematic_core::AtomIdx(j as u32));
let r = pi.distance(&pj);
if r > 1e-6 {
energy += K_COULOMB * charges[i] * charges[j] / r;
}
}
}
energy
}
fn compute_kinetic_energy_and_temp(
velocities: &[Point3],
masses: &[f64],
_target_temp: f64,
) -> (f64, f64) {
let mut ke = 0.0;
for (i, v) in velocities.iter().enumerate() {
let speed_sq = v.x * v.x + v.y * v.y + v.z * v.z;
ke += 0.5 * masses[i] * speed_sq;
}
let dof = (3 * velocities.len() - 3).max(1) as f64; let temp = 2.0 * ke / (dof * K_BOLTZMANN);
(ke, temp)
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_smiles::parse;
#[test]
fn test_md_ethane_nve() {
let mol = parse("CC").expect("ethane");
let coords = crate::generate_coords(&mol);
let config = MDConfig {
timestep_fs: 0.5,
steps: 50,
temperature_k: 300.0,
thermostat: Thermostat::None,
save_every: 10,
coulomb: false,
};
let traj = run_md(&mol, coords, &config);
assert!(!traj.frames.is_empty());
assert!(traj.frames[0].potential_energy > -1e10); }
#[test]
fn test_md_ethane_nvt() {
let mol = parse("CC").expect("ethane");
let coords = crate::generate_coords(&mol);
let config = MDConfig {
timestep_fs: 0.5,
steps: 100,
temperature_k: 300.0,
thermostat: Thermostat::Berendsen { tau_fs: 50.0 },
save_every: 20,
coulomb: false,
};
let traj = run_md(&mol, coords, &config);
assert!(!traj.frames.is_empty());
let final_frame = traj.frames.last().unwrap();
assert!(final_frame.temperature_k > 0.0);
}
#[test]
fn test_berendsen_zero_temperature_no_nan() {
let mol = parse("C").expect("methane");
let coords = crate::generate_coords(&mol);
let config = MDConfig {
timestep_fs: 1.0,
steps: 1,
temperature_k: 300.0,
thermostat: Thermostat::Berendsen { tau_fs: 100.0 },
save_every: 1,
coulomb: false,
};
let traj = run_md(&mol, coords, &config);
assert!(!traj.frames.is_empty());
let frame = traj.frames.first().unwrap();
for (i, coord) in frame.coords.points.iter().enumerate() {
assert!(coord.x.is_finite(), "coord[{}].x is NaN", i);
assert!(coord.y.is_finite(), "coord[{}].y is NaN", i);
assert!(coord.z.is_finite(), "coord[{}].z is NaN", i);
}
}
}