1use serde::{Deserialize, Serialize};
7
8fn atomic_solvation_parameter(z: u8) -> f64 {
11 match z {
12 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,
23 }
24}
25
26fn intrinsic_born_radius(z: u8) -> f64 {
29 let vdw = match z {
30 1 => 1.20,
31 5 => 1.92,
32 6 => 1.70,
33 7 => 1.55,
34 8 => 1.52,
35 9 => 1.47,
36 14 => 2.10,
37 15 => 1.80,
38 16 => 1.80,
39 17 => 1.75,
40 35 => 1.85,
41 53 => 1.98,
42 _ => 1.70,
43 };
44 vdw * 0.8 }
46
47fn hct_descreening_scale(z: u8) -> f64 {
53 match z {
54 1 => 0.85,
55 6 => 0.72,
56 7 => 0.79,
57 8 => 0.85,
58 9 => 0.88,
59 15 => 0.86,
60 16 => 0.96,
61 _ => 0.80,
62 }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct NonPolarSolvation {
68 pub energy_kcal_mol: f64,
70 pub atom_contributions: Vec<f64>,
72 pub atom_sasa: Vec<f64>,
74 pub total_sasa: f64,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct GbSolvation {
81 pub electrostatic_energy_kcal_mol: f64,
83 pub nonpolar_energy_kcal_mol: f64,
85 pub total_energy_kcal_mol: f64,
87 pub born_radii: Vec<f64>,
89 pub charges: Vec<f64>,
91 pub solvent_dielectric: f64,
93 pub solute_dielectric: f64,
95}
96
97pub fn compute_nonpolar_solvation(
101 elements: &[u8],
102 positions: &[[f64; 3]],
103 probe_radius: Option<f64>,
104) -> NonPolarSolvation {
105 let sasa = crate::surface::sasa::compute_sasa(elements, positions, probe_radius, Some(960));
106
107 let mut atom_contributions = Vec::with_capacity(elements.len());
108 for (i, &z) in elements.iter().enumerate() {
109 let asp = atomic_solvation_parameter(z);
110 let contrib = asp * sasa.atom_sasa[i] / 1000.0;
112 atom_contributions.push(contrib);
113 }
114
115 let energy_kcal_mol: f64 = atom_contributions.iter().sum();
116
117 NonPolarSolvation {
118 energy_kcal_mol,
119 atom_contributions,
120 atom_sasa: sasa.atom_sasa,
121 total_sasa: sasa.total_sasa,
122 }
123}
124
125pub fn compute_born_radii(elements: &[u8], positions: &[[f64; 3]]) -> Vec<f64> {
129 let n = elements.len();
130
131 let compute_one = |i: usize| -> f64 {
132 let rho_i = intrinsic_born_radius(elements[i]);
133 let mut integral = 0.0;
134
135 for j in 0..n {
136 if i == j {
137 continue;
138 }
139
140 let rho_j = intrinsic_born_radius(elements[j]);
141 let scale_j = hct_descreening_scale(elements[j]);
142 let scaled_rj = rho_j * scale_j;
143
144 let dx = positions[i][0] - positions[j][0];
145 let dy = positions[i][1] - positions[j][1];
146 let dz = positions[i][2] - positions[j][2];
147 let rij = (dx * dx + dy * dy + dz * dz).sqrt();
148
149 if rij > rho_i + scaled_rj {
150 let ljr = if rij > scaled_rj && scaled_rj > 1e-10 {
151 (rij / scaled_rj).ln()
152 } else {
153 0.0
154 };
155 let denom1 = (rij - scaled_rj).max(1e-10);
156 let denom2 = rij + scaled_rj;
157 let denom3 = (rij * rij - scaled_rj * scaled_rj).abs().max(1e-10);
158 integral += 0.5 * (1.0 / denom1 - 1.0 / denom2)
159 + scaled_rj * ljr / (2.0 * rij * denom3.max(1e-10));
160 } else if rij + rho_i > scaled_rj {
161 let denom = (rij - scaled_rj).abs().max(1e-10);
162 integral += 0.5 * (1.0 / denom - 1.0 / (rij + scaled_rj));
163 }
164 }
165
166 let inv_r = 1.0 / rho_i - integral;
167 let born_r = if inv_r > 1e-10 { 1.0 / inv_r } else { 50.0 };
168 born_r.max(rho_i)
169 };
170
171 #[cfg(feature = "parallel")]
172 {
173 use rayon::prelude::*;
174 (0..n).into_par_iter().map(compute_one).collect()
175 }
176 #[cfg(not(feature = "parallel"))]
177 {
178 (0..n).map(compute_one).collect()
179 }
180}
181
182pub fn compute_gb_solvation(
188 elements: &[u8],
189 positions: &[[f64; 3]],
190 charges: &[f64],
191 solvent_dielectric: Option<f64>,
192 solute_dielectric: Option<f64>,
193 probe_radius: Option<f64>,
194) -> GbSolvation {
195 let n = elements.len();
196 let eps_out = solvent_dielectric.unwrap_or(78.5); let eps_in = solute_dielectric.unwrap_or(1.0);
198
199 let born_radii = compute_born_radii(elements, positions);
200
201 let prefactor = -332.05 * 0.5 * (1.0 / eps_in - 1.0 / eps_out);
203
204 let compute_row = |i: usize| -> f64 {
205 let mut row_energy = 0.0;
206 let qi = charges[i];
207 for j in i..n {
208 let qj = charges[j];
209 if qi.abs() < 1e-12 && qj.abs() < 1e-12 {
210 continue;
211 }
212 let rij_sq = if i == j {
213 0.0
214 } else {
215 let dx = positions[i][0] - positions[j][0];
216 let dy = positions[i][1] - positions[j][1];
217 let dz = positions[i][2] - positions[j][2];
218 dx * dx + dy * dy + dz * dz
219 };
220 let ri_rj = born_radii[i] * born_radii[j];
221 let f_gb = (rij_sq + ri_rj * (-rij_sq / (4.0 * ri_rj).max(1e-10)).exp()).sqrt();
222 let factor = if i == j { 1.0 } else { 2.0 };
223 row_energy += factor * prefactor * qi * qj / f_gb;
224 }
225 row_energy
226 };
227
228 #[cfg(feature = "parallel")]
229 let elec_energy: f64 = {
230 use rayon::prelude::*;
231 (0..n).into_par_iter().map(compute_row).sum()
232 };
233 #[cfg(not(feature = "parallel"))]
234 let elec_energy: f64 = (0..n).map(compute_row).sum();
235
236 let nonpolar = compute_nonpolar_solvation(elements, positions, probe_radius);
238
239 GbSolvation {
240 electrostatic_energy_kcal_mol: elec_energy,
241 nonpolar_energy_kcal_mol: nonpolar.energy_kcal_mol,
242 total_energy_kcal_mol: elec_energy + nonpolar.energy_kcal_mol,
243 born_radii,
244 charges: charges.to_vec(),
245 solvent_dielectric: eps_out,
246 solute_dielectric: eps_in,
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn test_nonpolar_solvation_methane() {
256 let elements = vec![6u8];
258 let positions = vec![[0.0, 0.0, 0.0]];
259 let result = compute_nonpolar_solvation(&elements, &positions, None);
260 assert!(
261 result.energy_kcal_mol > 0.0,
262 "Carbon ASP should be positive"
263 );
264 assert!(result.total_sasa > 0.0);
265 }
266
267 #[test]
268 fn test_born_radii_positive() {
269 let elements = vec![8u8, 1, 1];
270 let positions = vec![[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
271 let radii = compute_born_radii(&elements, &positions);
272 assert_eq!(radii.len(), 3);
273 for r in &radii {
274 assert!(*r > 0.0, "Born radius should be positive, got {}", r);
275 assert!(*r <= 50.0, "Born radius should be <= 50 Å, got {}", r);
276 }
277 }
278
279 #[test]
280 fn test_gb_solvation_water() {
281 let elements = vec![8u8, 1, 1];
282 let positions = vec![[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
283 let charges = vec![-0.834, 0.417, 0.417]; let result = compute_gb_solvation(&elements, &positions, &charges, None, None, None);
285 assert!(
287 result.electrostatic_energy_kcal_mol < 0.0,
288 "Water GB energy should be negative, got {}",
289 result.electrostatic_energy_kcal_mol
290 );
291 }
292
293 #[test]
294 fn test_neutral_molecule_near_zero() {
295 let elements = vec![6u8, 1, 1, 1, 1];
297 let positions = vec![
298 [0.0, 0.0, 0.0],
299 [1.09, 0.0, 0.0],
300 [-0.36, 1.03, 0.0],
301 [-0.36, -0.52, 0.89],
302 [-0.36, -0.52, -0.89],
303 ];
304 let charges = vec![0.0, 0.0, 0.0, 0.0, 0.0];
305 let result = compute_gb_solvation(&elements, &positions, &charges, None, None, None);
306 assert!(
307 result.electrostatic_energy_kcal_mol.abs() < 1e-6,
308 "Zero-charge should give zero electrostatic solvation"
309 );
310 }
311}