use crate::constants::codata2018::BOHR_RADIUS_ANGSTROMS as A0_BOHR;
use crate::integrals::multipoles::DerivedMultipoleParams;
use crate::parameters::ParameterModel;
use crate::properties::dipole::E_ANGSTROM_TO_DEBYE;
use crate::types::{AlignedMatrix, MolecularBatch};
use std::f64::consts::PI;
pub const ANGSTROM_TO_BOHR: f64 = 1.0 / A0_BOHR;
pub const AU_TO_EV: f64 = 27.211386245988;
pub fn bondi_vdw_radius_angstrom(z: u8) -> f64 {
match z {
1 => 1.20, 2 => 1.40, 3 => 1.82, 4 => 1.53, 5 => 1.92, 6 => 1.70, 7 => 1.55, 8 => 1.52, 9 => 1.47, 10 => 1.54, 11 => 2.27, 12 => 1.73, 13 => 1.84, 14 => 2.10, 15 => 1.80, 16 => 1.80, 17 => 1.75, 18 => 1.88, 19 => 2.75, 20 => 2.31, 26 => 2.05, 28 => 1.63, 29 => 1.40, 30 => 1.39, 35 => 1.85, 53 => 1.98, _ => 1.80,
}
}
#[derive(Debug, Clone)]
pub struct EspOptions {
pub shell_multipliers: Vec<f64>,
pub points_per_shell: usize,
pub net_charge: f64,
}
impl Default for EspOptions {
fn default() -> Self {
Self {
shell_multipliers: vec![1.4, 1.6, 1.8, 2.0],
points_per_shell: 64,
net_charge: 0.0,
}
}
}
#[derive(Debug, Clone)]
pub struct EspResult {
pub charges: Vec<f64>,
pub dipole_debye: [f64; 3],
pub dipole_magnitude_debye: f64,
pub rms_error_ev: f64,
pub num_grid_points: usize,
}
fn generate_fibonacci_sphere_points(n: usize) -> Vec<[f64; 3]> {
let mut points = Vec::with_capacity(n);
let phi_golden = PI * (3.0 - 5.0f64.sqrt());
for i in 0..n {
let y = 1.0 - (i as f64 / ((n - 1).max(1) as f64)) * 2.0;
let radius = (1.0 - y * y).max(0.0).sqrt();
let theta = phi_golden * (i as f64);
let x = theta.cos() * radius;
let z = theta.sin() * radius;
points.push([x, y, z]);
}
points
}
#[allow(clippy::needless_range_loop)]
fn solve_linear_system(a: &mut [Vec<f64>], b: &mut [f64]) -> Result<Vec<f64>, String> {
let n = b.len();
assert_eq!(a.len(), n);
for k in 0..n {
let mut max_val = a[k][k].abs();
let mut pivot_row = k;
for p in (k + 1)..n {
let val = a[p][k].abs();
if val > max_val {
max_val = val;
pivot_row = p;
}
}
if max_val < 1e-14 {
return Err("Singular or ill-conditioned matrix in ESP charge regression".to_string());
}
if pivot_row != k {
a.swap(k, pivot_row);
b.swap(k, pivot_row);
}
let pivot = a[k][k];
for i in (k + 1)..n {
let factor = a[i][k] / pivot;
for j in k..n {
let val = a[k][j];
a[i][j] -= factor * val;
}
b[i] -= factor * b[k];
}
}
let mut x = vec![0.0; n];
for i in (0..n).rev() {
let mut sum = b[i];
for j in (i + 1)..n {
sum -= a[i][j] * x[j];
}
x[i] = sum / a[i][i];
}
Ok(x)
}
#[allow(clippy::needless_range_loop)]
pub fn compute_esp_charges(
batch: &MolecularBatch,
model: &dyn ParameterModel,
density: &AlignedMatrix<f64>,
opts: &EspOptions,
) -> Result<EspResult, String> {
let natoms = batch.natoms;
if natoms == 0 {
return Err("Cannot compute ESP charges for empty molecular batch".to_string());
}
let vdw_radii: Vec<f64> = batch
.atomic_numbers
.iter()
.map(|&z| bondi_vdw_radius_angstrom(z))
.collect();
let sphere_points = generate_fibonacci_sphere_points(opts.points_per_shell);
let mut grid_points: Vec<[f64; 3]> = Vec::new();
for (a, &r_vdw) in vdw_radii.iter().enumerate().take(natoms) {
let ra = [batch.x[a], batch.y[a], batch.z[a]];
for &multiplier in &opts.shell_multipliers {
let shell_radius = multiplier * r_vdw;
for &p in &sphere_points {
let candidate = [
ra[0] + shell_radius * p[0],
ra[1] + shell_radius * p[1],
ra[2] + shell_radius * p[2],
];
let mut inside_any = false;
for (b, &rb_vdw) in vdw_radii.iter().enumerate().take(natoms) {
if a == b {
continue;
}
let rb = [batch.x[b], batch.y[b], batch.z[b]];
let dist_sq = (candidate[0] - rb[0]).powi(2)
+ (candidate[1] - rb[1]).powi(2)
+ (candidate[2] - rb[2]).powi(2);
if dist_sq < rb_vdw * rb_vdw {
inside_any = true;
break;
}
}
if !inside_any {
grid_points.push(candidate);
}
}
}
}
let m_grid = grid_points.len();
if m_grid < natoms {
return Err(format!(
"Insufficient grid points ({}) for {} atoms in ESP fitting",
m_grid, natoms
));
}
let mut core_charges = Vec::with_capacity(natoms);
let mut elec_pops = Vec::with_capacity(natoms);
let mut hyb_dipoles = Vec::with_capacity(natoms);
for a in 0..natoms {
let z = batch.atomic_numbers[a];
let p = model
.get_element(z)
.ok_or_else(|| format!("Unsupported element Z={} in ESP parameter model", z))?;
core_charges.push(p.core_charge);
let start = batch.orbital_offsets[a];
let norbs_a = batch.basis_types[a].num_orbitals();
let mut pop_a = 0.0;
for o in 0..norbs_a {
pop_a += density.get(start + o, start + o);
}
elec_pops.push(pop_a);
let mut d_vec = [0.0; 3];
if norbs_a >= 4 {
let mp = DerivedMultipoleParams::from_element(&p);
let d1 = mp.dd; let s_orb = start;
let px_orb = start + 1;
let py_orb = start + 2;
let pz_orb = start + 3;
d_vec[0] = 2.0 * density.get(s_orb, px_orb) * d1;
d_vec[1] = 2.0 * density.get(s_orb, py_orb) * d1;
d_vec[2] = 2.0 * density.get(s_orb, pz_orb) * d1;
}
hyb_dipoles.push(d_vec);
}
let mut v_quantum = Vec::with_capacity(m_grid);
let mut inv_dist = vec![vec![0.0; natoms]; m_grid];
for (k, r_k) in grid_points.iter().enumerate() {
let mut v_k = 0.0;
for a in 0..natoms {
let dx = r_k[0] - batch.x[a];
let dy = r_k[1] - batch.y[a];
let dz = r_k[2] - batch.z[a];
let dist_ang = (dx * dx + dy * dy + dz * dz).sqrt();
let dist_bohr = dist_ang * ANGSTROM_TO_BOHR;
let inv_r_bohr = 1.0 / dist_bohr;
inv_dist[k][a] = inv_r_bohr;
let net_atom_charge = core_charges[a] - elec_pops[a];
v_k += net_atom_charge * inv_r_bohr;
let d_vec = hyb_dipoles[a];
let dx_bohr = dx * ANGSTROM_TO_BOHR;
let dy_bohr = dy * ANGSTROM_TO_BOHR;
let dz_bohr = dz * ANGSTROM_TO_BOHR;
let dot_product_bohr = d_vec[0] * dx_bohr + d_vec[1] * dy_bohr + d_vec[2] * dz_bohr;
let inv_r3_bohr = inv_r_bohr.powi(3);
v_k -= dot_product_bohr * inv_r3_bohr;
}
v_quantum.push(v_k);
}
let dim = natoms + 1;
let mut a_mat = vec![vec![0.0; dim]; dim];
let mut b_vec = vec![0.0; dim];
for i in 0..natoms {
for j in 0..natoms {
let mut sum_inv_prod = 0.0;
for k in 0..m_grid {
sum_inv_prod += inv_dist[k][i] * inv_dist[k][j];
}
a_mat[i][j] = sum_inv_prod;
}
a_mat[i][natoms] = 1.0;
a_mat[natoms][i] = 1.0;
let mut sum_v_inv = 0.0;
for k in 0..m_grid {
sum_v_inv += v_quantum[k] * inv_dist[k][i];
}
b_vec[i] = sum_v_inv;
}
a_mat[natoms][natoms] = 0.0;
b_vec[natoms] = opts.net_charge;
let solution = solve_linear_system(&mut a_mat, &mut b_vec)?;
let charges: Vec<f64> = solution[0..natoms].to_vec();
let mut sum_err_sq = 0.0;
for k in 0..m_grid {
let mut v_fitted = 0.0;
for a in 0..natoms {
v_fitted += charges[a] * inv_dist[k][a];
}
let diff = (v_quantum[k] - v_fitted) * AU_TO_EV;
sum_err_sq += diff * diff;
}
let rms_error_ev = (sum_err_sq / m_grid as f64).sqrt();
let mut dipole_debye = [0.0; 3];
for a in 0..natoms {
let q = charges[a];
dipole_debye[0] += q * batch.x[a] * E_ANGSTROM_TO_DEBYE;
dipole_debye[1] += q * batch.y[a] * E_ANGSTROM_TO_DEBYE;
dipole_debye[2] += q * batch.z[a] * E_ANGSTROM_TO_DEBYE;
}
let dipole_magnitude_debye =
(dipole_debye[0].powi(2) + dipole_debye[1].powi(2) + dipole_debye[2].powi(2)).sqrt();
Ok(EspResult {
charges,
dipole_debye,
dipole_magnitude_debye,
rms_error_ev,
num_grid_points: m_grid,
})
}