use crate::constants::codata2018::{
BOLTZMANN_CONSTANT_J_K, EV_TO_KCAL_MOL, GAS_CONSTANT_CAL, PLANCK_CONSTANT_ERG_S,
};
use crate::constants::standard_atomic_mass;
use crate::gradients::nuclear_gradients::{
compute_cartesian_gradients_with_options, GradientWorkspace,
};
use crate::parameters::ParameterModel;
use crate::scf::eigensolver::diagonalize_symmetric;
use crate::scf::scf_loop::{run_rhf_scf_with_options, ScfOptions};
use crate::types::{AlignedMatrix, AlignedVec64, MolecularBatch, ScfWorkspace};
pub const KCAL_MOL_A2_AMU_TO_CM1: f64 = 108.59135859237747;
pub const CM1_TO_KCAL_MOL: f64 = 0.00285914370690045;
pub const CM1_TO_KELVIN: f64 = 1.43877687750393;
#[derive(Debug, Clone)]
pub struct HessianOptions {
pub delta: f64,
pub recompute_scf: bool,
pub use_nddo: bool,
pub project_external: bool,
pub temperature_k: f64,
pub pressure_atm: f64,
pub rotational_symmetry_number: f64,
pub custom_masses: Option<Vec<f64>>,
}
impl Default for HessianOptions {
fn default() -> Self {
Self {
delta: 1.0e-3,
recompute_scf: true,
use_nddo: true,
project_external: true,
temperature_k: 298.15,
pressure_atm: 1.0,
rotational_symmetry_number: 1.0,
custom_masses: None,
}
}
}
#[derive(Debug, Clone)]
pub struct NormalMode {
pub frequency_cm1: f64,
pub reduced_mass_amu: f64,
pub force_constant_mdyne_a: f64,
pub displacements: Vec<[f64; 3]>,
}
#[derive(Debug, Clone, Default)]
pub struct ThermodynamicProperties {
pub temperature_k: f64,
pub pressure_atm: f64,
pub zpve_kcal_mol: f64,
pub e_vib_cal_mol: f64,
pub e_rot_cal_mol: f64,
pub e_trans_cal_mol: f64,
pub enthalpy_thermal_cal_mol: f64,
pub cv_vib_cal_k_mol: f64,
pub cv_rot_cal_k_mol: f64,
pub cp_trans_cal_k_mol: f64,
pub cp_total_cal_k_mol: f64,
pub entropy_vib_cal_k_mol: f64,
pub entropy_rot_cal_k_mol: f64,
pub entropy_trans_cal_k_mol: f64,
pub entropy_total_cal_k_mol: f64,
pub gibbs_correction_kcal_mol: f64,
}
#[derive(Debug, Clone)]
pub struct HessianResult {
pub cartesian_hessian: AlignedMatrix<f64>,
pub mass_weighted_hessian: AlignedMatrix<f64>,
pub all_frequencies_cm1: Vec<f64>,
pub vibrational_frequencies_cm1: Vec<f64>,
pub normal_modes: Vec<NormalMode>,
pub zpve_kcal_mol: f64,
pub thermo: ThermodynamicProperties,
}
pub fn compute_hessian_and_frequencies(
batch: &mut MolecularBatch,
model: &dyn ParameterModel,
ws: &mut ScfWorkspace,
scf_opts: &ScfOptions,
hess_opts: &HessianOptions,
) -> HessianResult {
let natoms = batch.natoms;
let n3 = 3 * natoms;
assert!(natoms >= 1, "MolecularBatch must contain at least 1 atom");
let masses: Vec<f64> = if let Some(ref cm) = hess_opts.custom_masses {
if cm.len() == natoms {
cm.clone()
} else {
batch
.atomic_numbers
.iter()
.map(|&z| standard_atomic_mass(z))
.collect()
}
} else {
batch
.atomic_numbers
.iter()
.map(|&z| standard_atomic_mass(z))
.collect()
};
let base_scf = run_rhf_scf_with_options(batch, model, ws, scf_opts);
assert!(
base_scf.converged,
"Base SCF must converge before computing Hessian"
);
let init_density = ws.density.clone();
let mut grad_ws = GradientWorkspace::allocate(batch.norbs);
let mut g_plus = vec![[0.0; 3]; natoms];
let mut g_minus = vec![[0.0; 3]; natoms];
let mut cartesian_hessian = AlignedMatrix::zeroed(n3, n3);
let delta = hess_opts.delta;
let inv_2delta = 1.0 / (2.0 * delta);
for a in 0..natoms {
for alpha in 0..3 {
let col = 3 * a + alpha;
displace_coord(batch, a, alpha, delta);
if hess_opts.recompute_scf {
ws.density.clone_from(&init_density);
run_rhf_scf_with_options(batch, model, ws, scf_opts);
}
compute_cartesian_gradients_with_options(
batch,
model,
&ws.density,
&mut grad_ws,
&mut g_plus,
hess_opts.use_nddo,
);
displace_coord(batch, a, alpha, -2.0 * delta);
if hess_opts.recompute_scf {
ws.density.clone_from(&init_density);
run_rhf_scf_with_options(batch, model, ws, scf_opts);
}
compute_cartesian_gradients_with_options(
batch,
model,
&ws.density,
&mut grad_ws,
&mut g_minus,
hess_opts.use_nddo,
);
displace_coord(batch, a, alpha, delta);
for b in 0..natoms {
for beta in 0..3 {
let row = 3 * b + beta;
let dg = (g_plus[b][beta] - g_minus[b][beta]) * inv_2delta * EV_TO_KCAL_MOL;
cartesian_hessian.set(row, col, dg);
}
}
}
}
ws.density.clone_from(&init_density);
for i in 0..n3 {
for j in (i + 1)..n3 {
let val = 0.5 * (cartesian_hessian.get(i, j) + cartesian_hessian.get(j, i));
cartesian_hessian.set(i, j, val);
cartesian_hessian.set(j, i, val);
}
}
let mut mass_weighted_hessian = AlignedMatrix::zeroed(n3, n3);
for a in 0..natoms {
let ma = masses[a];
for alpha in 0..3 {
let i = 3 * a + alpha;
for (b, &mb) in masses.iter().enumerate().take(natoms) {
let inv_sqrt_m = 1.0 / (ma * mb).sqrt();
for beta in 0..3 {
let j = 3 * b + beta;
let h_val = cartesian_hessian.get(i, j);
mass_weighted_hessian.set(i, j, h_val * inv_sqrt_m);
}
}
}
}
let mut mat_to_diag = mass_weighted_hessian.clone();
if hess_opts.project_external && natoms > 1 {
project_out_translations_and_rotations(batch, &masses, &mut mat_to_diag);
}
let mut eigenvalues = AlignedVec64::zeroed(n3);
let mut eigenvectors = AlignedMatrix::zeroed(n3, n3);
diagonalize_symmetric(&mat_to_diag, &mut eigenvalues, &mut eigenvectors);
let mut all_frequencies_cm1 = Vec::with_capacity(n3);
for &lam in eigenvalues.iter() {
let freq = if lam >= 0.0 {
lam.sqrt() * KCAL_MOL_A2_AMU_TO_CM1
} else {
-(-lam).sqrt() * KCAL_MOL_A2_AMU_TO_CM1
};
all_frequencies_cm1.push(freq);
}
let n_ext = if natoms == 1 {
3
} else if is_linear_molecule(batch) {
5
} else {
6
};
let n_vib = n3.saturating_sub(n_ext);
let mut indexed_modes: Vec<(usize, f64)> = eigenvalues
.iter()
.enumerate()
.map(|(idx, &lam)| (idx, lam.abs()))
.collect();
indexed_modes.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let mut is_ext = vec![false; n3];
for &(idx, _) in indexed_modes.iter().take(n_ext) {
is_ext[idx] = true;
}
let mut vibrational_frequencies_cm1 = Vec::with_capacity(n_vib);
for i in 0..n3 {
if !is_ext[i] {
vibrational_frequencies_cm1.push(all_frequencies_cm1[i]);
}
}
let mut normal_modes = Vec::with_capacity(n3);
for (k, &freq) in all_frequencies_cm1.iter().enumerate().take(n3) {
let mut displacements = Vec::with_capacity(natoms);
let mut sum_disp_sq = 0.0;
for (a, &ma) in masses.iter().enumerate().take(natoms) {
let inv_sqrt_m = 1.0 / ma.sqrt();
let dx = eigenvectors.get(3 * a, k) * inv_sqrt_m;
let dy = eigenvectors.get(3 * a + 1, k) * inv_sqrt_m;
let dz = eigenvectors.get(3 * a + 2, k) * inv_sqrt_m;
sum_disp_sq += dx * dx + dy * dy + dz * dz;
displacements.push([dx, dy, dz]);
}
let norm = sum_disp_sq.sqrt();
if norm > 1e-12 {
for d in displacements.iter_mut() {
d[0] /= norm;
d[1] /= norm;
d[2] /= norm;
}
}
let eff_mass = if norm > 1e-12 {
1.0 / (norm * norm)
} else {
1.0
};
let k_mdyne = (freq.abs() / 1302.7937).powi(2) * eff_mass;
normal_modes.push(NormalMode {
frequency_cm1: freq,
reduced_mass_amu: eff_mass,
force_constant_mdyne_a: k_mdyne,
displacements,
});
}
let zpve_kcal_mol = vibrational_frequencies_cm1
.iter()
.filter(|&&f| f > 0.0)
.map(|&f| 0.5 * f * CM1_TO_KCAL_MOL)
.sum();
let thermo = compute_thermodynamics(
batch,
&masses,
&vibrational_frequencies_cm1,
zpve_kcal_mol,
hess_opts.temperature_k,
hess_opts.pressure_atm,
hess_opts.rotational_symmetry_number,
);
HessianResult {
cartesian_hessian,
mass_weighted_hessian,
all_frequencies_cm1,
vibrational_frequencies_cm1,
normal_modes,
zpve_kcal_mol,
thermo,
}
}
#[inline(always)]
fn displace_coord(batch: &mut MolecularBatch, a: usize, alpha: usize, delta: f64) {
match alpha {
0 => batch.x[a] += delta,
1 => batch.y[a] += delta,
2 => batch.z[a] += delta,
_ => unreachable!(),
}
}
fn is_linear_molecule(batch: &MolecularBatch) -> bool {
let natoms = batch.natoms;
if natoms <= 2 {
return true;
}
let v0 = [
batch.x[1] - batch.x[0],
batch.y[1] - batch.y[0],
batch.z[1] - batch.z[0],
];
let n0 = (v0[0] * v0[0] + v0[1] * v0[1] + v0[2] * v0[2]).sqrt();
if n0 < 1e-6 {
return true;
}
for i in 2..natoms {
let vi = [
batch.x[i] - batch.x[0],
batch.y[i] - batch.y[0],
batch.z[i] - batch.z[0],
];
let cx = v0[1] * vi[2] - v0[2] * vi[1];
let cy = v0[2] * vi[0] - v0[0] * vi[2];
let cz = v0[0] * vi[1] - v0[1] * vi[0];
let cross_norm = (cx * cx + cy * cy + cz * cz).sqrt();
if cross_norm > 1e-4 {
return false;
}
}
true
}
fn project_out_translations_and_rotations(
batch: &MolecularBatch,
masses: &[f64],
h_mw: &mut AlignedMatrix<f64>,
) {
let natoms = batch.natoms;
let n3 = 3 * natoms;
let mut total_mass = 0.0;
let mut com = [0.0; 3];
for (i, &m) in masses.iter().enumerate().take(natoms) {
total_mass += m;
com[0] += m * batch.x[i];
com[1] += m * batch.y[i];
com[2] += m * batch.z[i];
}
com[0] /= total_mass;
com[1] /= total_mass;
com[2] /= total_mass;
let mut tr_vecs = vec![vec![0.0; n3]; 6];
for i in 0..natoms {
let sqrt_m = masses[i].sqrt();
let rx = batch.x[i] - com[0];
let ry = batch.y[i] - com[1];
let rz = batch.z[i] - com[2];
tr_vecs[0][3 * i] = sqrt_m;
tr_vecs[1][3 * i + 1] = sqrt_m;
tr_vecs[2][3 * i + 2] = sqrt_m;
tr_vecs[3][3 * i + 1] = -sqrt_m * rz;
tr_vecs[3][3 * i + 2] = sqrt_m * ry;
tr_vecs[4][3 * i] = sqrt_m * rz;
tr_vecs[4][3 * i + 2] = -sqrt_m * rx;
tr_vecs[5][3 * i] = -sqrt_m * ry;
tr_vecs[5][3 * i + 1] = sqrt_m * rx;
}
let mut basis: Vec<Vec<f64>> = Vec::with_capacity(6);
for v in tr_vecs.iter_mut() {
for u in &basis {
let dot: f64 = v.iter().zip(u.iter()).map(|(&a, &b)| a * b).sum();
for (vi, &ui) in v.iter_mut().zip(u.iter()) {
*vi -= dot * ui;
}
}
let norm_sq: f64 = v.iter().map(|&a| a * a).sum();
if norm_sq > 1e-10 {
let inv_norm = 1.0 / norm_sq.sqrt();
for vi in v.iter_mut() {
*vi *= inv_norm;
}
basis.push(v.clone());
}
}
let mut p = AlignedMatrix::zeroed(n3, n3);
for i in 0..n3 {
p.set(i, i, 1.0);
}
for u in &basis {
for i in 0..n3 {
let ui = u[i];
if ui.abs() < 1e-15 {
continue;
}
for (j, &uj) in u.iter().enumerate().take(n3) {
let current = p.get(i, j);
p.set(i, j, current - ui * uj);
}
}
}
let mut tmp = AlignedMatrix::zeroed(n3, n3);
for i in 0..n3 {
for j in 0..n3 {
let mut sum = 0.0;
for k in 0..n3 {
sum += h_mw.get(i, k) * p.get(k, j);
}
tmp.set(i, j, sum);
}
}
for i in 0..n3 {
for j in 0..n3 {
let mut sum = 0.0;
for k in 0..n3 {
sum += p.get(i, k) * tmp.get(k, j);
}
h_mw.set(i, j, sum);
}
}
}
fn compute_thermodynamics(
batch: &MolecularBatch,
masses: &[f64],
vib_frequencies: &[f64],
zpve_kcal_mol: f64,
t: f64,
p_atm: f64,
sigma: f64,
) -> ThermodynamicProperties {
assert!(t > 0.0, "Temperature must be strictly positive");
let r = GAS_CONSTANT_CAL; let rt = r * t;
let mut e_vib = 0.0;
let mut cv_vib = 0.0;
let mut s_vib = 0.0;
for &nu in vib_frequencies {
if nu <= 10.0 {
continue;
}
let theta = CM1_TO_KELVIN * nu;
let x = theta / t;
if x > 100.0 {
continue;
}
let exp_x = x.exp();
let exp_m1 = exp_x - 1.0;
e_vib += r * theta / exp_m1;
cv_vib += r * (x * x * exp_x) / (exp_m1 * exp_m1);
let exp_neg_x = (-x).exp();
s_vib += r * (x / exp_m1 - (1.0 - exp_neg_x).ln());
}
let natoms = batch.natoms;
let (e_rot, cv_rot, s_rot) = if natoms == 1 {
(0.0, 0.0, 0.0)
} else if is_linear_molecule(batch) {
let e = rt;
let cv = r;
let mut total_mass = 0.0;
let mut com = [0.0; 3];
for (i, &m) in masses.iter().enumerate().take(natoms) {
total_mass += m;
com[0] += m * batch.x[i];
com[1] += m * batch.y[i];
com[2] += m * batch.z[i];
}
com[0] /= total_mass;
com[1] /= total_mass;
com[2] /= total_mass;
let mut i_rot = 0.0;
for (i, &m) in masses.iter().enumerate().take(natoms) {
let dx = batch.x[i] - com[0];
let dy = batch.y[i] - com[1];
let dz = batch.z[i] - com[2];
let r2 = dx * dx + dy * dy + dz * dz;
i_rot += m * r2;
}
let i_kg_m2 = i_rot * 1.66053906660e-47;
let theta_rot = (PLANCK_CONSTANT_ERG_S * 1e-7).powi(2)
/ (8.0 * std::f64::consts::PI.powi(2) * i_kg_m2 * BOLTZMANN_CONSTANT_J_K);
let s = r * (1.0 + (t / (sigma * theta_rot)).ln());
(e, cv, s.max(0.0))
} else {
let e = 1.5 * rt;
let cv = 1.5 * r;
let moments = compute_principal_moments_of_inertia(batch, masses);
let conv = 1.66053906660e-47;
let h_si = PLANCK_CONSTANT_ERG_S * 1e-7;
let kb_si = BOLTZMANN_CONSTANT_J_K;
let pre = h_si * h_si / (8.0 * std::f64::consts::PI.powi(2) * kb_si);
let t_a = pre / (moments[0].max(1e-6) * conv);
let t_b = pre / (moments[1].max(1e-6) * conv);
let t_c = pre / (moments[2].max(1e-6) * conv);
let q_rot = (std::f64::consts::PI.sqrt() / sigma) * (t.powi(3) / (t_a * t_b * t_c)).sqrt();
let s = r * (1.5 + q_rot.max(1e-10).ln());
(e, cv, s.max(0.0))
};
let e_trans = 1.5 * rt;
let cp_trans = 2.5 * r;
let total_mass_amu: f64 = masses.iter().sum();
let m_kg = total_mass_amu * 1.66053906660e-27;
let p_pa = p_atm * 101325.0;
let h_si = PLANCK_CONSTANT_ERG_S * 1e-7;
let kb_si = BOLTZMANN_CONSTANT_J_K;
let lambda = h_si / (2.0 * std::f64::consts::PI * m_kg * kb_si * t).sqrt();
let v_per_mol = kb_si * t / p_pa; let q_trans = v_per_mol / lambda.powi(3);
let s_trans = r * (2.5 + q_trans.max(1e-10).ln());
let enthalpy_thermal_cal_mol = e_vib + e_rot + e_trans + rt;
let cp_total_cal_k_mol = cv_vib + cv_rot + cp_trans;
let entropy_total_cal_k_mol = s_vib + s_rot + s_trans;
let h_total_kcal = (zpve_kcal_mol * 1000.0 + enthalpy_thermal_cal_mol) / 1000.0;
let ts_kcal = (t * entropy_total_cal_k_mol) / 1000.0;
let gibbs_correction_kcal_mol = h_total_kcal - ts_kcal;
ThermodynamicProperties {
temperature_k: t,
pressure_atm: p_atm,
zpve_kcal_mol,
e_vib_cal_mol: e_vib,
e_rot_cal_mol: e_rot,
e_trans_cal_mol: e_trans,
enthalpy_thermal_cal_mol,
cv_vib_cal_k_mol: cv_vib,
cv_rot_cal_k_mol: cv_rot,
cp_trans_cal_k_mol: cp_trans,
cp_total_cal_k_mol,
entropy_vib_cal_k_mol: s_vib,
entropy_rot_cal_k_mol: s_rot,
entropy_trans_cal_k_mol: s_trans,
entropy_total_cal_k_mol,
gibbs_correction_kcal_mol,
}
}
fn compute_principal_moments_of_inertia(batch: &MolecularBatch, masses: &[f64]) -> [f64; 3] {
let natoms = batch.natoms;
let mut total_mass = 0.0;
let mut com = [0.0; 3];
for (i, &m) in masses.iter().enumerate().take(natoms) {
total_mass += m;
com[0] += m * batch.x[i];
com[1] += m * batch.y[i];
com[2] += m * batch.z[i];
}
com[0] /= total_mass;
com[1] /= total_mass;
com[2] /= total_mass;
let mut i_mat = AlignedMatrix::zeroed(3, 3);
for (i, &m) in masses.iter().enumerate().take(natoms) {
let x = batch.x[i] - com[0];
let y = batch.y[i] - com[1];
let z = batch.z[i] - com[2];
let i_xx = i_mat.get(0, 0) + m * (y * y + z * z);
let i_yy = i_mat.get(1, 1) + m * (x * x + z * z);
let i_zz = i_mat.get(2, 2) + m * (x * x + y * y);
let i_xy = i_mat.get(0, 1) - m * x * y;
let i_xz = i_mat.get(0, 2) - m * x * z;
let i_yz = i_mat.get(1, 2) - m * y * z;
i_mat.set(0, 0, i_xx);
i_mat.set(1, 1, i_yy);
i_mat.set(2, 2, i_zz);
i_mat.set(0, 1, i_xy);
i_mat.set(1, 0, i_xy);
i_mat.set(0, 2, i_xz);
i_mat.set(2, 0, i_xz);
i_mat.set(1, 2, i_yz);
i_mat.set(2, 1, i_yz);
}
let mut eigs = AlignedVec64::zeroed(3);
let mut evecs = AlignedMatrix::zeroed(3, 3);
diagonalize_symmetric(&i_mat, &mut eigs, &mut evecs);
[eigs[0], eigs[1], eigs[2]]
}