use serde::{Deserialize, Serialize};
fn atomic_solvation_parameter(z: u8) -> f64 {
match z {
1 => 7.0, 6 => 12.0, 7 => -116.0, 8 => -166.0, 9 => -5.0, 15 => -20.0, 16 => -32.0, 17 => 18.0, 35 => 22.0, 53 => 28.0, _ => 0.0,
}
}
fn intrinsic_born_radius(z: u8) -> f64 {
let vdw = match z {
1 => 1.20,
5 => 1.92,
6 => 1.70,
7 => 1.55,
8 => 1.52,
9 => 1.47,
14 => 2.10,
15 => 1.80,
16 => 1.80,
17 => 1.75,
35 => 1.85,
53 => 1.98,
_ => 1.70,
};
vdw * 0.8 }
fn hct_descreening_scale(z: u8) -> f64 {
match z {
1 => 0.85,
6 => 0.72,
7 => 0.79,
8 => 0.85,
9 => 0.88,
15 => 0.86,
16 => 0.96,
_ => 0.80,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NonPolarSolvation {
pub energy_kcal_mol: f64,
pub atom_contributions: Vec<f64>,
pub atom_sasa: Vec<f64>,
pub total_sasa: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GbSolvation {
pub electrostatic_energy_kcal_mol: f64,
pub nonpolar_energy_kcal_mol: f64,
pub total_energy_kcal_mol: f64,
pub born_radii: Vec<f64>,
pub charges: Vec<f64>,
pub solvent_dielectric: f64,
pub solute_dielectric: f64,
}
pub fn compute_nonpolar_solvation(
elements: &[u8],
positions: &[[f64; 3]],
probe_radius: Option<f64>,
) -> NonPolarSolvation {
let sasa = crate::surface::sasa::compute_sasa(elements, positions, probe_radius, Some(960));
let mut atom_contributions = Vec::with_capacity(elements.len());
for (i, &z) in elements.iter().enumerate() {
let asp = atomic_solvation_parameter(z);
let contrib = asp * sasa.atom_sasa[i] / 1000.0;
atom_contributions.push(contrib);
}
let energy_kcal_mol: f64 = atom_contributions.iter().sum();
NonPolarSolvation {
energy_kcal_mol,
atom_contributions,
atom_sasa: sasa.atom_sasa,
total_sasa: sasa.total_sasa,
}
}
pub fn compute_born_radii(elements: &[u8], positions: &[[f64; 3]]) -> Vec<f64> {
let n = elements.len();
let compute_one = |i: usize| -> f64 {
let rho_i = intrinsic_born_radius(elements[i]);
let mut integral = 0.0;
for j in 0..n {
if i == j {
continue;
}
let rho_j = intrinsic_born_radius(elements[j]);
let scale_j = hct_descreening_scale(elements[j]);
let scaled_rj = rho_j * scale_j;
let dx = positions[i][0] - positions[j][0];
let dy = positions[i][1] - positions[j][1];
let dz = positions[i][2] - positions[j][2];
let rij = (dx * dx + dy * dy + dz * dz).sqrt();
if rij > rho_i + scaled_rj {
let ljr = if rij > scaled_rj && scaled_rj > 1e-10 {
(rij / scaled_rj).ln()
} else {
0.0
};
let denom1 = (rij - scaled_rj).max(1e-10);
let denom2 = rij + scaled_rj;
let denom3 = (rij * rij - scaled_rj * scaled_rj).abs().max(1e-10);
integral += 0.5 * (1.0 / denom1 - 1.0 / denom2)
+ scaled_rj * ljr / (2.0 * rij * denom3.max(1e-10));
} else if rij + rho_i > scaled_rj {
let denom = (rij - scaled_rj).abs().max(1e-10);
integral += 0.5 * (1.0 / denom - 1.0 / (rij + scaled_rj));
}
}
let inv_r = 1.0 / rho_i - integral;
let born_r = if inv_r > 1e-10 { 1.0 / inv_r } else { 50.0 };
born_r.max(rho_i)
};
#[cfg(feature = "parallel")]
{
use rayon::prelude::*;
(0..n).into_par_iter().map(compute_one).collect()
}
#[cfg(not(feature = "parallel"))]
{
(0..n).map(compute_one).collect()
}
}
pub fn compute_gb_solvation(
elements: &[u8],
positions: &[[f64; 3]],
charges: &[f64],
solvent_dielectric: Option<f64>,
solute_dielectric: Option<f64>,
probe_radius: Option<f64>,
) -> GbSolvation {
let n = elements.len();
let eps_out = solvent_dielectric.unwrap_or(78.5); let eps_in = solute_dielectric.unwrap_or(1.0);
let born_radii = compute_born_radii(elements, positions);
let prefactor = -332.05 * 0.5 * (1.0 / eps_in - 1.0 / eps_out);
let compute_row = |i: usize| -> f64 {
let mut row_energy = 0.0;
let qi = charges[i];
for j in i..n {
let qj = charges[j];
if qi.abs() < 1e-12 && qj.abs() < 1e-12 {
continue;
}
let rij_sq = if i == j {
0.0
} else {
let dx = positions[i][0] - positions[j][0];
let dy = positions[i][1] - positions[j][1];
let dz = positions[i][2] - positions[j][2];
dx * dx + dy * dy + dz * dz
};
let ri_rj = born_radii[i] * born_radii[j];
let f_gb = (rij_sq + ri_rj * (-rij_sq / (4.0 * ri_rj).max(1e-10)).exp()).sqrt();
let factor = if i == j { 1.0 } else { 2.0 };
row_energy += factor * prefactor * qi * qj / f_gb;
}
row_energy
};
#[cfg(feature = "parallel")]
let elec_energy: f64 = {
use rayon::prelude::*;
(0..n).into_par_iter().map(compute_row).sum()
};
#[cfg(not(feature = "parallel"))]
let elec_energy: f64 = (0..n).map(compute_row).sum();
let nonpolar = compute_nonpolar_solvation(elements, positions, probe_radius);
GbSolvation {
electrostatic_energy_kcal_mol: elec_energy,
nonpolar_energy_kcal_mol: nonpolar.energy_kcal_mol,
total_energy_kcal_mol: elec_energy + nonpolar.energy_kcal_mol,
born_radii,
charges: charges.to_vec(),
solvent_dielectric: eps_out,
solute_dielectric: eps_in,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_nonpolar_solvation_methane() {
let elements = vec![6u8];
let positions = vec![[0.0, 0.0, 0.0]];
let result = compute_nonpolar_solvation(&elements, &positions, None);
assert!(
result.energy_kcal_mol > 0.0,
"Carbon ASP should be positive"
);
assert!(result.total_sasa > 0.0);
}
#[test]
fn test_born_radii_positive() {
let elements = vec![8u8, 1, 1];
let positions = vec![[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
let radii = compute_born_radii(&elements, &positions);
assert_eq!(radii.len(), 3);
for r in &radii {
assert!(*r > 0.0, "Born radius should be positive, got {}", r);
assert!(*r <= 50.0, "Born radius should be <= 50 Å, got {}", r);
}
}
#[test]
fn test_gb_solvation_water() {
let elements = vec![8u8, 1, 1];
let positions = vec![[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
let charges = vec![-0.834, 0.417, 0.417]; let result = compute_gb_solvation(&elements, &positions, &charges, None, None, None);
assert!(
result.electrostatic_energy_kcal_mol < 0.0,
"Water GB energy should be negative, got {}",
result.electrostatic_energy_kcal_mol
);
}
#[test]
fn test_neutral_molecule_near_zero() {
let elements = vec![6u8, 1, 1, 1, 1];
let positions = vec![
[0.0, 0.0, 0.0],
[1.09, 0.0, 0.0],
[-0.36, 1.03, 0.0],
[-0.36, -0.52, 0.89],
[-0.36, -0.52, -0.89],
];
let charges = vec![0.0, 0.0, 0.0, 0.0, 0.0];
let result = compute_gb_solvation(&elements, &positions, &charges, None, None, None);
assert!(
result.electrostatic_energy_kcal_mol.abs() < 1e-6,
"Zero-charge should give zero electrostatic solvation"
);
}
}