Skip to main content

sci_form/solvation_alpb/
solvation.rs

1//! ALPB Solvation Energy — Core
2//!
3//! Generalized Born electrostatic solvation with ALPB correction
4//! and non-polar SASA contribution.
5//!
6//! ## Units
7//! - Positions and Born radii: Ångström (Å)
8//! - Charges: elementary charges (e)
9//! - Energies (result): kcal/mol
10//! - Surface tension: kcal/(mol·Å²)
11//! - Coulomb constant: 332.063 kcal·Å/e² = e²·Nₐ/(4πε₀)
12
13use super::born::{compute_born_radii, gb_kernel};
14use serde::{Deserialize, Serialize};
15
16/// Configuration for ALPB solvation.
17///
18/// Default: water (`ε = 78.5`), standard probe radius (1.4 Å),
19/// surface tension 0.005 kcal/(mol·Å²).
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct AlpbConfig {
22    /// Solvent dielectric constant.
23    pub solvent_dielectric: f64,
24    /// Probe radius for SASA (Å).
25    pub probe_radius: f64,
26    /// Surface tension for non-polar term (kcal/(mol·Å²)).
27    pub surface_tension: f64,
28}
29
30impl Default for AlpbConfig {
31    fn default() -> Self {
32        Self {
33            solvent_dielectric: 78.5,
34            probe_radius: 1.4,
35            surface_tension: 0.005,
36        }
37    }
38}
39
40/// Result of ALPB solvation calculation.
41///
42/// Energies are in kcal/mol. Born radii are in Å.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct AlpbResult {
45    /// Electrostatic solvation energy (kcal/mol).
46    pub electrostatic_energy: f64,
47    /// Non-polar solvation energy (kcal/mol).
48    pub nonpolar_energy: f64,
49    /// Total solvation energy (kcal/mol).
50    pub total_energy: f64,
51    /// Born radii (Å).
52    pub born_radii: Vec<f64>,
53    /// ALPB correction factor.
54    pub alpb_factor: f64,
55}
56
57/// Compute ALPB solvation energy.
58///
59/// ALPB applies a Klamt correction to standard GB:
60/// E_ALPB = E_GB · (ε-1)/(ε + A·x)  where A = 0.571412
61pub fn compute_alpb_solvation(
62    elements: &[u8],
63    positions: &[[f64; 3]],
64    charges: &[f64],
65    config: &AlpbConfig,
66) -> AlpbResult {
67    let n = elements.len();
68    let born = compute_born_radii(elements, positions, config.probe_radius);
69
70    let eps = config.solvent_dielectric;
71    let prefactor = -0.5 * (1.0 - 1.0 / eps);
72
73    let mut e_gb = 0.0;
74    let mut e_coulomb = 0.0;
75
76    for i in 0..n {
77        for j in i..n {
78            let dx = positions[i][0] - positions[j][0];
79            let dy = positions[i][1] - positions[j][1];
80            let dz = positions[i][2] - positions[j][2];
81            let r_ij = (dx * dx + dy * dy + dz * dz).sqrt();
82
83            let f = gb_kernel(r_ij, born.radii[i], born.radii[j]);
84            let factor = if i == j { 1.0 } else { 2.0 };
85
86            e_gb += factor * charges[i] * charges[j] / f;
87
88            if i != j && r_ij > 1e-10 {
89                e_coulomb += factor * charges[i] * charges[j] / r_ij;
90            }
91        }
92    }
93
94    e_gb *= prefactor;
95
96    // ALPB correction (Klamt scheme)
97    let klamt_a = 0.571412;
98    let (alpb_factor, e_alpb) = if (eps - 1.0).abs() < 1e-12 {
99        (0.0, 0.0)
100    } else {
101        let x = if e_coulomb.abs() > 1e-15 {
102            e_gb / (prefactor * e_coulomb)
103        } else {
104            1.0
105        };
106        let af = (eps - 1.0) / (eps + klamt_a * x.abs().max(0.01));
107        let e = e_gb * af / ((eps - 1.0) / eps);
108        (af, e)
109    };
110
111    // Coulomb constant in kcal·Å/e²: e²·Nₐ / (4π ε₀) ≈ 332.06
112    let coulomb_kcal_ang = 332.063;
113    let e_electrostatic = e_alpb * coulomb_kcal_ang;
114
115    // Non-polar SASA term — use the full Shrake-Rupley implementation
116    let sasa_result =
117        crate::surface::sasa::compute_sasa(elements, positions, Some(config.probe_radius), None);
118    let sasa_total = sasa_result.total_sasa;
119
120    let e_nonpolar = config.surface_tension * sasa_total;
121
122    AlpbResult {
123        electrostatic_energy: e_electrostatic,
124        nonpolar_energy: e_nonpolar,
125        total_energy: e_electrostatic + e_nonpolar,
126        born_radii: born.radii,
127        alpb_factor,
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn test_alpb_water_in_water() {
137        let elements = [8, 1, 1];
138        let positions = [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
139        let charges = [-0.834, 0.417, 0.417];
140        let config = AlpbConfig::default();
141        let result = compute_alpb_solvation(&elements, &positions, &charges, &config);
142        assert!(result.total_energy.is_finite());
143        assert!(
144            result.electrostatic_energy < 0.0 || result.electrostatic_energy.abs() < 1.0,
145            "Electrostatic = {}",
146            result.electrostatic_energy
147        );
148    }
149
150    #[test]
151    fn test_alpb_vacuum_dielectric() {
152        let elements = [8, 1, 1];
153        let positions = [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
154        let charges = [-0.834, 0.417, 0.417];
155        let config = AlpbConfig {
156            solvent_dielectric: 1.0,
157            ..Default::default()
158        };
159        let result = compute_alpb_solvation(&elements, &positions, &charges, &config);
160        assert!(
161            result.electrostatic_energy.abs() < 1e-10,
162            "Vacuum solvation should be 0: {}",
163            result.electrostatic_energy
164        );
165    }
166
167    #[test]
168    fn test_alpb_nonpolar_positive() {
169        let elements = [6, 1, 1, 1, 1];
170        let positions = [
171            [0.0, 0.0, 0.0],
172            [0.63, 0.63, 0.63],
173            [-0.63, -0.63, 0.63],
174            [-0.63, 0.63, -0.63],
175            [0.63, -0.63, -0.63],
176        ];
177        let charges = [0.0, 0.0, 0.0, 0.0, 0.0];
178        let config = AlpbConfig::default();
179        let result = compute_alpb_solvation(&elements, &positions, &charges, &config);
180        assert!(result.nonpolar_energy >= 0.0);
181    }
182
183    #[test]
184    fn test_alpb_factor_bounded() {
185        let elements = [8, 1, 1];
186        let positions = [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
187        let charges = [-0.834, 0.417, 0.417];
188        let config = AlpbConfig::default();
189        let result = compute_alpb_solvation(&elements, &positions, &charges, &config);
190        assert!(
191            result.alpb_factor >= 0.0 && result.alpb_factor <= 1.0,
192            "ALPB factor should be in [0,1]: {}",
193            result.alpb_factor
194        );
195    }
196}