Skip to main content

sci_form/
solvation.rs

1//! Implicit solvation modeling: ASP non-polar solvation and Generalized Born (GB).
2//!
3//! Implements the Still Generalized Born model with Hawkins-Cramer-Truhlar (HCT)
4//! descreening for effective Born radii, combined with non-polar SASA-based solvation.
5
6use serde::{Deserialize, Serialize};
7
8/// Atomic solvation parameters (ASP) for non-polar solvation energy in cal/(mol·Å²).
9/// Values from Wesson & Eisenberg, Protein Science 1992.
10fn atomic_solvation_parameter(z: u8) -> f64 {
11    match z {
12        1 => 7.0,    // H: hydrophobic
13        6 => 12.0,   // C: hydrocarbon
14        7 => -116.0, // N: polar
15        8 => -166.0, // O: polar
16        9 => -5.0,   // F: slightly polar
17        15 => -20.0, // P: polar
18        16 => -32.0, // S: slightly polar
19        17 => 18.0,  // Cl: hydrophobic
20        35 => 22.0,  // Br: hydrophobic
21        53 => 28.0,  // I: hydrophobic
22        _ => 0.0,
23    }
24}
25
26/// Intrinsic Born radii (Å) used as initial radii for GB calculations.
27/// Based on Bondi vdW radii with scaling factor 0.8.
28fn 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 // HCT scaling
45}
46
47/// HCT descreening parameter by element.
48fn hct_descreening_scale(z: u8) -> f64 {
49    match z {
50        1 => 0.85,
51        6 => 0.72,
52        7 => 0.79,
53        8 => 0.85,
54        9 => 0.88,
55        15 => 0.86,
56        16 => 0.96,
57        _ => 0.80,
58    }
59}
60
61/// Result of non-polar solvation energy calculation.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct NonPolarSolvation {
64    /// Total non-polar solvation energy in kcal/mol.
65    pub energy_kcal_mol: f64,
66    /// Per-atom solvation contributions in kcal/mol.
67    pub atom_contributions: Vec<f64>,
68    /// Per-atom SASA values used (Ų).
69    pub atom_sasa: Vec<f64>,
70    /// Total SASA (Ų).
71    pub total_sasa: f64,
72}
73
74/// Result of Generalized Born electrostatic solvation calculation.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct GbSolvation {
77    /// Electrostatic solvation energy in kcal/mol.
78    pub electrostatic_energy_kcal_mol: f64,
79    /// Non-polar solvation energy in kcal/mol.
80    pub nonpolar_energy_kcal_mol: f64,
81    /// Total solvation energy (electrostatic + non-polar) in kcal/mol.
82    pub total_energy_kcal_mol: f64,
83    /// Effective Born radii for each atom (Å).
84    pub born_radii: Vec<f64>,
85    /// Partial charges used.
86    pub charges: Vec<f64>,
87    /// Solvent dielectric constant used.
88    pub solvent_dielectric: f64,
89    /// Solute dielectric constant used.
90    pub solute_dielectric: f64,
91}
92
93/// Compute non-polar solvation energy from per-atom SASA and atomic solvation parameters.
94///
95/// ΔG_np = Σ_i σ_i · A_i where σ_i is the ASP and A_i is the SASA of atom i.
96pub fn compute_nonpolar_solvation(
97    elements: &[u8],
98    positions: &[[f64; 3]],
99    probe_radius: Option<f64>,
100) -> NonPolarSolvation {
101    let sasa = crate::surface::sasa::compute_sasa(elements, positions, probe_radius, Some(960));
102
103    let mut atom_contributions = Vec::with_capacity(elements.len());
104    for (i, &z) in elements.iter().enumerate() {
105        let asp = atomic_solvation_parameter(z);
106        // Convert cal/(mol·Å²) to kcal/(mol·Å²)
107        let contrib = asp * sasa.atom_sasa[i] / 1000.0;
108        atom_contributions.push(contrib);
109    }
110
111    let energy_kcal_mol: f64 = atom_contributions.iter().sum();
112
113    NonPolarSolvation {
114        energy_kcal_mol,
115        atom_contributions,
116        atom_sasa: sasa.atom_sasa,
117        total_sasa: sasa.total_sasa,
118    }
119}
120
121/// Compute effective Born radii using the Hawkins-Cramer-Truhlar (HCT) pairwise descreening model.
122///
123/// R_i^eff = 1 / (1/ρ_i - I_i) where I_i is the descreening integral.
124pub fn compute_born_radii(elements: &[u8], positions: &[[f64; 3]]) -> Vec<f64> {
125    let n = elements.len();
126    let mut born_radii = Vec::with_capacity(n);
127
128    for i in 0..n {
129        let rho_i = intrinsic_born_radius(elements[i]);
130        let mut integral = 0.0;
131
132        for j in 0..n {
133            if i == j {
134                continue;
135            }
136
137            let rho_j = intrinsic_born_radius(elements[j]);
138            let scale_j = hct_descreening_scale(elements[j]);
139            let scaled_rj = rho_j * scale_j;
140
141            let dx = positions[i][0] - positions[j][0];
142            let dy = positions[i][1] - positions[j][1];
143            let dz = positions[i][2] - positions[j][2];
144            let rij = (dx * dx + dy * dy + dz * dz).sqrt();
145
146            if rij > rho_i + scaled_rj {
147                // Atom j is completely outside atom i's sphere
148                let term = 0.5
149                    * (1.0 / (rij - scaled_rj) - 1.0 / (rij + scaled_rj)
150                        + scaled_rj / (rij * rij - scaled_rj * scaled_rj)
151                            * (rij / (2.0 * (rij * rij - scaled_rj * scaled_rj).abs().max(1e-10))
152                                + 0.5 * (1.0 / rij).ln().exp() * 0.0));
153                // Simplified HCT integral: I_ij ≈ ½(1/(r-s) - 1/(r+s)) + s/(r²-s²)·ln(r/s)/(2r)
154                let ljr = if rij > scaled_rj && scaled_rj > 1e-10 {
155                    (rij / scaled_rj).ln()
156                } else {
157                    0.0
158                };
159                let denom1 = (rij - scaled_rj).max(1e-10);
160                let denom2 = rij + scaled_rj;
161                let denom3 = (rij * rij - scaled_rj * scaled_rj).abs().max(1e-10);
162                let _ = term;
163                integral += 0.5 * (1.0 / denom1 - 1.0 / denom2)
164                    + scaled_rj * ljr / (2.0 * rij * denom3.max(1e-10));
165            } else if rij + rho_i > scaled_rj {
166                // Partial overlap
167                let denom = (rij - scaled_rj).abs().max(1e-10);
168                integral += 0.5 * (1.0 / denom - 1.0 / (rij + scaled_rj));
169            }
170            // If fully enclosed, skip (integral contribution is handled differently)
171        }
172
173        let inv_r = 1.0 / rho_i - integral;
174        let born_r = if inv_r > 1e-10 { 1.0 / inv_r } else { 50.0 }; // cap at 50 Å
175        born_radii.push(born_r.max(rho_i)); // Born radius should not be smaller than intrinsic
176    }
177
178    born_radii
179}
180
181/// Compute Generalized Born electrostatic solvation energy using the Still equation.
182///
183/// ΔG_GB = -½(1/ε_in - 1/ε_out) Σ_{i,j} q_i·q_j / f_GB(r_ij, R_i, R_j)
184///
185/// where f_GB = sqrt(r_ij² + R_i·R_j·exp(-r_ij²/(4·R_i·R_j)))
186pub fn compute_gb_solvation(
187    elements: &[u8],
188    positions: &[[f64; 3]],
189    charges: &[f64],
190    solvent_dielectric: Option<f64>,
191    solute_dielectric: Option<f64>,
192    probe_radius: Option<f64>,
193) -> GbSolvation {
194    let n = elements.len();
195    let eps_out = solvent_dielectric.unwrap_or(78.5); // water at 25°C
196    let eps_in = solute_dielectric.unwrap_or(1.0);
197
198    let born_radii = compute_born_radii(elements, positions);
199
200    // Electrostatic GB energy
201    let prefactor = -332.05 * 0.5 * (1.0 / eps_in - 1.0 / eps_out); // 332.05 = e²/(4πε₀) in kcal·Å/mol
202    let mut elec_energy = 0.0;
203
204    for i in 0..n {
205        for j in i..n {
206            let qi = charges[i];
207            let qj = charges[j];
208            if qi.abs() < 1e-12 && qj.abs() < 1e-12 {
209                continue;
210            }
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
221            let ri_rj = born_radii[i] * born_radii[j];
222            let f_gb = (rij_sq + ri_rj * (-rij_sq / (4.0 * ri_rj).max(1e-10)).exp()).sqrt();
223
224            let factor = if i == j { 1.0 } else { 2.0 }; // count (i,j) and (j,i)
225            elec_energy += factor * prefactor * qi * qj / f_gb;
226        }
227    }
228
229    // Non-polar contribution
230    let nonpolar = compute_nonpolar_solvation(elements, positions, probe_radius);
231
232    GbSolvation {
233        electrostatic_energy_kcal_mol: elec_energy,
234        nonpolar_energy_kcal_mol: nonpolar.energy_kcal_mol,
235        total_energy_kcal_mol: elec_energy + nonpolar.energy_kcal_mol,
236        born_radii,
237        charges: charges.to_vec(),
238        solvent_dielectric: eps_out,
239        solute_dielectric: eps_in,
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn test_nonpolar_solvation_methane() {
249        // Single carbon → positive ASP → positive solvation
250        let elements = vec![6u8];
251        let positions = vec![[0.0, 0.0, 0.0]];
252        let result = compute_nonpolar_solvation(&elements, &positions, None);
253        assert!(
254            result.energy_kcal_mol > 0.0,
255            "Carbon ASP should be positive"
256        );
257        assert!(result.total_sasa > 0.0);
258    }
259
260    #[test]
261    fn test_born_radii_positive() {
262        let elements = vec![8u8, 1, 1];
263        let positions = vec![[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
264        let radii = compute_born_radii(&elements, &positions);
265        assert_eq!(radii.len(), 3);
266        for r in &radii {
267            assert!(*r > 0.0, "Born radius should be positive, got {}", r);
268            assert!(*r <= 50.0, "Born radius should be <= 50 Å, got {}", r);
269        }
270    }
271
272    #[test]
273    fn test_gb_solvation_water() {
274        let elements = vec![8u8, 1, 1];
275        let positions = vec![[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
276        let charges = vec![-0.834, 0.417, 0.417]; // TIP3P charges
277        let result = compute_gb_solvation(&elements, &positions, &charges, None, None, None);
278        // Water should have negative electrostatic solvation (stabilizing)
279        assert!(
280            result.electrostatic_energy_kcal_mol < 0.0,
281            "Water GB energy should be negative, got {}",
282            result.electrostatic_energy_kcal_mol
283        );
284    }
285
286    #[test]
287    fn test_neutral_molecule_near_zero() {
288        // All-zero charges → near-zero electrostatic solvation
289        let elements = vec![6u8, 1, 1, 1, 1];
290        let positions = vec![
291            [0.0, 0.0, 0.0],
292            [1.09, 0.0, 0.0],
293            [-0.36, 1.03, 0.0],
294            [-0.36, -0.52, 0.89],
295            [-0.36, -0.52, -0.89],
296        ];
297        let charges = vec![0.0, 0.0, 0.0, 0.0, 0.0];
298        let result = compute_gb_solvation(&elements, &positions, &charges, None, None, None);
299        assert!(
300            result.electrostatic_energy_kcal_mol.abs() < 1e-6,
301            "Zero-charge should give zero electrostatic solvation"
302        );
303    }
304}