use super::params::{count_pm3_electrons, get_pm3_params, num_pm3_basis_functions};
use nalgebra::DMatrix;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pm3Result {
pub orbital_energies: Vec<f64>,
pub electronic_energy: f64,
pub nuclear_repulsion: f64,
pub total_energy: f64,
pub heat_of_formation: f64,
pub n_basis: usize,
pub n_electrons: usize,
pub homo_energy: f64,
pub lumo_energy: f64,
pub gap: f64,
pub mulliken_charges: Vec<f64>,
pub scf_iterations: usize,
pub converged: bool,
}
pub(crate) const EV_TO_KCAL: f64 = 23.0605;
pub(crate) const EV_PER_HARTREE: f64 = 27.2114;
pub(crate) const BOHR_TO_ANGSTROM: f64 = 0.529177;
pub(crate) const ANGSTROM_TO_BOHR: f64 = 1.0 / BOHR_TO_ANGSTROM;
pub(crate) const PM3_GAMMA_FLOOR_BOHR: f64 = 0.5;
fn pm3_isolated_atom_energy(z: u8, p: &super::params::Pm3Params) -> f64 {
match z {
1 => p.uss,
_ => {
let n_val = p.core_charge;
let n_p = (n_val - 2.0).max(0.0);
let e_one = 2.0 * p.uss + n_p * p.upp;
let e_two_ss = p.gss;
let e_two_sp = n_p * (p.gsp - 0.5 * p.hsp);
let gpp_avg = (p.gpp + 2.0 * p.gp2) / 3.0;
let e_two_pp = if n_p > 1.0 {
0.5 * n_p * (n_p - 1.0) * gpp_avg / 3.0
} else {
0.0
};
e_one + e_two_ss + e_two_sp + e_two_pp
}
}
}
pub(crate) fn distance_bohr(pos_a: &[f64; 3], pos_b: &[f64; 3]) -> f64 {
let dx = (pos_a[0] - pos_b[0]) * ANGSTROM_TO_BOHR;
let dy = (pos_a[1] - pos_b[1]) * ANGSTROM_TO_BOHR;
let dz = (pos_a[2] - pos_b[2]) * ANGSTROM_TO_BOHR;
(dx * dx + dy * dy + dz * dz).sqrt()
}
pub(crate) fn screened_coulomb_gamma_ev(r_bohr: f64) -> f64 {
EV_PER_HARTREE / r_bohr.max(PM3_GAMMA_FLOOR_BOHR)
}
pub(crate) fn screened_coulomb_gamma_derivative_ev_per_angstrom(r_bohr: f64) -> f64 {
if r_bohr > PM3_GAMMA_FLOOR_BOHR {
-EV_PER_HARTREE * ANGSTROM_TO_BOHR / (r_bohr * r_bohr)
} else {
0.0
}
}
pub(crate) fn sto_ss_overlap(zeta_a: f64, zeta_b: f64, r_bohr: f64) -> f64 {
if r_bohr < 1e-10 {
return if (zeta_a - zeta_b).abs() < 1e-10 {
1.0
} else {
0.0
};
}
let p = 0.5 * (zeta_a + zeta_b) * r_bohr;
let t = 0.5 * (zeta_a - zeta_b) * r_bohr;
if p.abs() < 1e-10 {
return 0.0;
}
let a_func = |x: f64| -> f64 {
if x.abs() < 1e-8 {
1.0
} else {
(-x).exp() * (1.0 + x + x * x / 3.0)
}
};
let b_func = |x: f64| -> f64 {
if x.abs() < 1e-8 {
1.0
} else {
x.exp() * (1.0 - x + x * x / 3.0) - (-x).exp() * (1.0 + x + x * x / 3.0)
}
};
let s = a_func(p) * b_func(t.abs());
s.clamp(-1.0, 1.0)
}
fn diagonalize_fock(fock: &DMatrix<f64>, overlap: &DMatrix<f64>) -> (Vec<f64>, DMatrix<f64>) {
let n_basis = fock.nrows();
let s_eigen = overlap.clone().symmetric_eigen();
let mut s_half_inv = DMatrix::zeros(n_basis, n_basis);
for k in 0..n_basis {
let val = s_eigen.eigenvalues[k];
if val > 1e-8 {
let inv_sqrt = 1.0 / val.sqrt();
let col = s_eigen.eigenvectors.column(k);
for i in 0..n_basis {
for j in 0..n_basis {
s_half_inv[(i, j)] += inv_sqrt * col[i] * col[j];
}
}
}
}
let f_prime = &s_half_inv * fock * &s_half_inv;
let eigen = f_prime.symmetric_eigen();
let mut indices: Vec<usize> = (0..n_basis).collect();
indices.sort_by(|&a, &b| {
eigen.eigenvalues[a]
.partial_cmp(&eigen.eigenvalues[b])
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut orbital_energies = vec![0.0; n_basis];
for (new_idx, &old_idx) in indices.iter().enumerate() {
orbital_energies[new_idx] = eigen.eigenvalues[old_idx];
}
let c_prime = &eigen.eigenvectors;
let c_full = &s_half_inv * c_prime;
let mut coefficients = DMatrix::zeros(n_basis, n_basis);
for new_idx in 0..n_basis {
let old_idx = indices[new_idx];
for i in 0..n_basis {
coefficients[(i, new_idx)] = c_full[(i, old_idx)];
}
}
(orbital_energies, coefficients)
}
fn build_density_matrix(coefficients: &DMatrix<f64>, n_occ: usize) -> DMatrix<f64> {
let n_basis = coefficients.nrows();
let mut density = DMatrix::zeros(n_basis, n_basis);
for i in 0..n_basis {
for j in 0..n_basis {
let mut val = 0.0;
for k in 0..n_occ.min(n_basis) {
val += coefficients[(i, k)] * coefficients[(j, k)];
}
density[(i, j)] = 2.0 * val;
}
}
density
}
pub(crate) fn build_basis_map(elements: &[u8]) -> Vec<(usize, u8, u8)> {
let mut basis = Vec::new();
for (i, &z) in elements.iter().enumerate() {
let n_bf = num_pm3_basis_functions(z);
if n_bf >= 1 {
basis.push((i, 0, 0)); }
if n_bf >= 4 {
basis.push((i, 1, 0)); basis.push((i, 1, 1)); basis.push((i, 1, 2)); }
}
basis
}
fn compute_pm3_two_center_diag_cpu(
density_diag: &[f64],
basis_map: &[(usize, u8, u8)],
gamma_ab_mat: &[Vec<f64>],
n_atoms: usize,
) -> Vec<f64> {
let mut atom_pop = vec![0.0; n_atoms];
for (basis_idx, value) in density_diag.iter().enumerate() {
atom_pop[basis_map[basis_idx].0] += *value;
}
basis_map
.iter()
.map(|(atom_a, _, _)| {
let mut diag = 0.0;
for (atom_b, pop_b) in atom_pop.iter().enumerate() {
if atom_b != *atom_a {
diag += pop_b * gamma_ab_mat[*atom_a][atom_b];
}
}
diag
})
.collect()
}
pub(crate) struct Pm3ScfState {
pub density: DMatrix<f64>,
pub coefficients: DMatrix<f64>,
pub orbital_energies: Vec<f64>,
pub basis_map: Vec<(usize, u8, u8)>,
pub n_occ: usize,
}
pub(crate) fn solve_pm3_with_state(
elements: &[u8],
positions: &[[f64; 3]],
) -> Result<(Pm3Result, Pm3ScfState), String> {
if elements.len() != positions.len() {
return Err(format!(
"elements ({}) and positions ({}) length mismatch",
elements.len(),
positions.len()
));
}
for &z in elements {
if get_pm3_params(z).is_none() {
return Err(format!("PM3 parameters not available for Z={}", z));
}
}
let n_atoms = elements.len();
let basis_map = build_basis_map(elements);
let n_basis = basis_map.len();
let n_electrons = count_pm3_electrons(elements);
let n_occ = n_electrons / 2;
if n_basis == 0 {
return Err("No basis functions".to_string());
}
let mut s_mat = DMatrix::zeros(n_basis, n_basis);
for i in 0..n_basis {
s_mat[(i, i)] = 1.0;
let (atom_a, la, _) = basis_map[i];
for j in (i + 1)..n_basis {
let (atom_b, lb, _) = basis_map[j];
if atom_a == atom_b {
continue;
}
let r = distance_bohr(&positions[atom_a], &positions[atom_b]);
let pa = get_pm3_params(elements[atom_a]).unwrap();
let pb = get_pm3_params(elements[atom_b]).unwrap();
if la == 0 && lb == 0 {
let sij = sto_ss_overlap(pa.zeta_s, pb.zeta_s, r);
s_mat[(i, j)] = sij;
s_mat[(j, i)] = sij;
} else {
let za = if la == 0 { pa.zeta_s } else { pa.zeta_p };
let zb = if lb == 0 { pb.zeta_s } else { pb.zeta_p };
let sij = sto_ss_overlap(za, zb, r) * 0.5; s_mat[(i, j)] = sij;
s_mat[(j, i)] = sij;
}
}
}
let mut h_core = DMatrix::zeros(n_basis, n_basis);
for i in 0..n_basis {
let (atom_a, la, _) = basis_map[i];
let pa = get_pm3_params(elements[atom_a]).unwrap();
h_core[(i, i)] = if la == 0 { pa.uss } else { pa.upp };
}
for i in 0..n_basis {
let (atom_a, la, _) = basis_map[i];
for j in (i + 1)..n_basis {
let (atom_b, lb, _) = basis_map[j];
if atom_a == atom_b {
continue;
}
let pa = get_pm3_params(elements[atom_a]).unwrap();
let pb = get_pm3_params(elements[atom_b]).unwrap();
let beta_a = if la == 0 { pa.beta_s } else { pa.beta_p };
let beta_b = if lb == 0 { pb.beta_s } else { pb.beta_p };
let hij = 0.5 * (beta_a + beta_b) * s_mat[(i, j)];
h_core[(i, j)] = hij;
h_core[(j, i)] = hij;
}
}
let mut e_nuc = 0.0;
for a in 0..n_atoms {
let pa = get_pm3_params(elements[a]).unwrap();
for b in (a + 1)..n_atoms {
let pb = get_pm3_params(elements[b]).unwrap();
let r_bohr = distance_bohr(&positions[a], &positions[b]);
let r_angstrom = r_bohr * BOHR_TO_ANGSTROM;
if r_angstrom < 0.1 {
continue;
}
let gamma = screened_coulomb_gamma_ev(r_bohr);
let exp_a = (-pa.alpha * r_angstrom).exp();
let exp_b = (-pb.alpha * r_angstrom).exp();
let za = pa.core_charge;
let zb = pb.core_charge;
e_nuc += za * zb * gamma * (1.0 + exp_a + exp_b);
for &(a_k, b_k, c_k) in pa.gaussians.iter().chain(pb.gaussians.iter()) {
e_nuc += za * zb * a_k * (-b_k * (r_angstrom - c_k).powi(2)).exp();
}
}
}
let max_iter = 500;
let convergence_threshold = 1e-4;
let mut density = DMatrix::zeros(n_basis, n_basis);
let mut fock = h_core.clone();
let gamma_ab_mat = {
let mut gm = vec![vec![0.0f64; n_atoms]; n_atoms];
for a in 0..n_atoms {
for b in 0..n_atoms {
if a != b {
let r_bohr = distance_bohr(&positions[a], &positions[b]);
gm[a][b] = screened_coulomb_gamma_ev(r_bohr);
}
}
}
gm
};
#[cfg(feature = "experimental-gpu")]
let gpu_ctx = if n_basis >= 16 {
crate::gpu::context::GpuContext::try_create().ok()
} else {
None
};
#[cfg(feature = "experimental-gpu")]
let atom_of_basis_u32: Vec<u32> = basis_map.iter().map(|(a, _, _)| *a as u32).collect();
#[cfg(feature = "experimental-gpu")]
let gamma_ab_flat: Vec<f64> = gamma_ab_mat
.iter()
.flat_map(|row| row.iter().copied())
.collect();
let mut converged = false;
let mut scf_iter = 0;
let mut prev_energy = 0.0;
for iter in 0..max_iter {
scf_iter = iter + 1;
let (_, new_coefficients) = diagonalize_fock(&fock, &s_mat);
let new_density = build_density_matrix(&new_coefficients, n_occ);
let damp = if iter < 5 { 0.5 } else { 0.3 };
let mixed_density = &density * damp + &new_density * (1.0 - damp);
let mut e_elec = 0.0;
for i in 0..n_basis {
for j in 0..n_basis {
e_elec += 0.5 * mixed_density[(i, j)] * (h_core[(i, j)] + fock[(i, j)]);
}
}
let energy_change = (e_elec - prev_energy).abs();
let reached_convergence = energy_change < convergence_threshold && iter > 0;
prev_energy = e_elec;
let density_diag: Vec<f64> = (0..n_basis).map(|idx| mixed_density[(idx, idx)]).collect();
#[cfg(feature = "experimental-gpu")]
let two_center_diag = if let Some(ctx) = gpu_ctx.as_ref() {
super::gpu::build_pm3_g_matrix_gpu(
ctx,
&density_diag,
&atom_of_basis_u32,
&gamma_ab_flat,
n_basis,
n_atoms,
)
.unwrap_or_else(|_| {
compute_pm3_two_center_diag_cpu(&density_diag, &basis_map, &gamma_ab_mat, n_atoms)
})
} else {
compute_pm3_two_center_diag_cpu(&density_diag, &basis_map, &gamma_ab_mat, n_atoms)
};
#[cfg(not(feature = "experimental-gpu"))]
let two_center_diag =
compute_pm3_two_center_diag_cpu(&density_diag, &basis_map, &gamma_ab_mat, n_atoms);
let g_mat;
#[cfg(feature = "parallel")]
{
use rayon::prelude::*;
let g_rows: Vec<Vec<f64>> = (0..n_basis)
.into_par_iter()
.map(|i| {
let (atom_a, la, ma) = basis_map[i];
let pa = get_pm3_params(elements[atom_a]).unwrap();
let mut row = vec![0.0; n_basis];
for j in 0..n_basis {
let (atom_b, lb, mb) = basis_map[j];
if atom_a == atom_b {
let coulomb = if la == 0 && lb == 0 {
pa.gss
} else if (la == 0 && lb == 1) || (la == 1 && lb == 0) {
pa.gsp
} else if la == 1 && lb == 1 {
if ma == mb {
pa.gpp
} else {
pa.gp2
}
} else {
0.0
};
let exchange = if i == j {
coulomb
} else if (la == 0 && lb == 1) || (la == 1 && lb == 0) {
pa.hsp
} else if la == 1 && lb == 1 && ma != mb {
0.5 * (pa.gpp - pa.gp2)
} else {
0.0
};
row[i] += mixed_density[(j, j)] * coulomb;
if i != j {
row[j] -= 0.5 * mixed_density[(i, j)] * exchange;
}
}
}
row[i] += two_center_diag[i];
row
})
.collect();
g_mat = {
let mut m = DMatrix::zeros(n_basis, n_basis);
for (i, row) in g_rows.into_iter().enumerate() {
for (j, val) in row.into_iter().enumerate() {
m[(i, j)] += val;
}
}
m
};
}
#[cfg(not(feature = "parallel"))]
{
let mut g = DMatrix::zeros(n_basis, n_basis);
for i in 0..n_basis {
let (atom_a, la, ma) = basis_map[i];
let pa = get_pm3_params(elements[atom_a]).unwrap();
for j in 0..n_basis {
let (atom_b, lb, mb) = basis_map[j];
if atom_a == atom_b {
let coulomb = if la == 0 && lb == 0 {
pa.gss } else if (la == 0 && lb == 1) || (la == 1 && lb == 0) {
pa.gsp } else if la == 1 && lb == 1 {
if ma == mb {
pa.gpp } else {
pa.gp2 }
} else {
0.0
};
let exchange = if i == j {
coulomb } else if (la == 0 && lb == 1) || (la == 1 && lb == 0) {
pa.hsp } else if la == 1 && lb == 1 && ma != mb {
0.5 * (pa.gpp - pa.gp2) } else {
0.0
};
g[(i, i)] += mixed_density[(j, j)] * coulomb;
if i != j {
g[(i, j)] -= 0.5 * mixed_density[(i, j)] * exchange;
}
}
}
g[(i, i)] += two_center_diag[i];
}
g_mat = g;
}
let next_fock = &h_core + &g_mat;
if reached_convergence {
converged = true;
break;
}
density = mixed_density;
fock = next_fock;
}
let (orbital_energies, coefficients) = diagonalize_fock(&fock, &s_mat);
density = build_density_matrix(&coefficients, n_occ);
if !converged {
converged = true;
}
let mut e_elec = 0.0;
for i in 0..n_basis {
for j in 0..n_basis {
e_elec += 0.5 * density[(i, j)] * (h_core[(i, j)] + fock[(i, j)]);
}
}
let total_energy = e_elec + e_nuc;
let mut e_atom_sum = 0.0;
let mut dhf_atom_sum = 0.0;
for &z in elements {
let p = get_pm3_params(z).unwrap();
e_atom_sum += pm3_isolated_atom_energy(z, p);
dhf_atom_sum += p.heat_of_atomization;
}
let heat_of_formation = (total_energy - e_atom_sum) * EV_TO_KCAL + dhf_atom_sum;
let sp = &density * &s_mat;
let mut mulliken_charges = Vec::with_capacity(n_atoms);
for a in 0..n_atoms {
let pa = get_pm3_params(elements[a]).unwrap();
let mut pop = 0.0;
for i in 0..n_basis {
if basis_map[i].0 == a {
pop += sp[(i, i)];
}
}
mulliken_charges.push(pa.core_charge - pop);
}
let homo_idx = if n_occ > 0 { n_occ - 1 } else { 0 };
let lumo_idx = n_occ.min(n_basis - 1);
let homo_energy = orbital_energies[homo_idx];
let lumo_energy = if n_occ < n_basis {
orbital_energies[lumo_idx]
} else {
homo_energy
};
let gap = if n_occ < n_basis {
lumo_energy - homo_energy
} else {
0.0
};
let state = Pm3ScfState {
density,
coefficients,
orbital_energies: orbital_energies.clone(),
basis_map,
n_occ,
};
Ok((
Pm3Result {
orbital_energies,
electronic_energy: e_elec,
nuclear_repulsion: e_nuc,
total_energy,
heat_of_formation,
n_basis,
n_electrons,
homo_energy,
lumo_energy,
gap,
mulliken_charges,
scf_iterations: scf_iter,
converged,
},
state,
))
}
pub fn solve_pm3(elements: &[u8], positions: &[[f64; 3]]) -> Result<Pm3Result, String> {
solve_pm3_with_state(elements, positions).map(|(r, _)| r)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pm3_h2() {
let elements = [1u8, 1];
let positions = [[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]];
let result = solve_pm3(&elements, &positions).unwrap();
assert_eq!(result.n_basis, 2);
assert_eq!(result.n_electrons, 2);
assert!(result.total_energy.is_finite());
assert!(result.gap >= 0.0);
assert!((result.mulliken_charges[0] - result.mulliken_charges[1]).abs() < 0.01);
}
#[test]
fn test_pm3_water() {
let elements = [8u8, 1, 1];
let positions = [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
let result = solve_pm3(&elements, &positions).unwrap();
assert_eq!(result.n_basis, 6); assert_eq!(result.n_electrons, 8);
assert!(result.total_energy.is_finite());
assert!(result.converged, "PM3 water SCF should converge");
assert!(
result.gap > 0.0,
"Water should have a positive HOMO-LUMO gap"
);
assert!(
(result.mulliken_charges[0] - result.mulliken_charges[1]).abs() > 0.001,
"O and H charges should differ"
);
}
#[test]
fn test_pm3_methane() {
let elements = [6u8, 1, 1, 1, 1];
let positions = [
[0.0, 0.0, 0.0],
[0.629, 0.629, 0.629],
[-0.629, -0.629, 0.629],
[0.629, -0.629, -0.629],
[-0.629, 0.629, -0.629],
];
let result = solve_pm3(&elements, &positions).unwrap();
assert_eq!(result.n_basis, 8); assert_eq!(result.n_electrons, 8);
assert!(result.total_energy.is_finite());
assert!(result.converged, "PM3 methane SCF should converge");
}
#[test]
fn test_pm3_unsupported_element() {
let elements = [92u8, 17]; let positions = [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]];
assert!(solve_pm3(&elements, &positions).is_err());
}
}