Skip to main content

sci_form/surface/
sasa.rs

1//! Shrake-Rupley solvent-accessible surface area (SASA) algorithm.
2//!
3//! Distributes test points on a sphere around each atom (van der Waals radius
4//! plus probe radius), counts how many points are NOT buried inside a neighbor,
5//! and scales to area.
6//!
7//! Reference: A. Shrake & J.A. Rupley, *J. Mol. Biol.* **79**, 351–371, 1973.
8
9use serde::{Deserialize, Serialize};
10use std::f64::consts::PI;
11
12/// Default probe radius (water) in Ångström.
13pub const DEFAULT_PROBE_RADIUS: f64 = 1.4;
14
15/// Default number of test points per atom sphere (92 is a common choice,
16/// 960 or 2562 give higher accuracy).
17pub const DEFAULT_NUM_POINTS: usize = 960;
18
19/// Van der Waals radii (Å) by atomic number (Bondi radii).
20///
21/// Explicit radii: H(1), B(5), C(6), N(7), O(8), F(9), Si(14), P(15),
22/// S(16), Cl(17), Se(34), Br(35), I(53).
23/// All other elements (including transition metals, lanthanides, etc.)
24/// use a fallback radius of 1.70 Å.
25pub fn vdw_radius(z: u8) -> f64 {
26    match z {
27        1 => 1.20,
28        5 => 1.92,
29        6 => 1.70,
30        7 => 1.55,
31        8 => 1.52,
32        9 => 1.47,
33        14 => 2.10,
34        15 => 1.80,
35        16 => 1.80,
36        17 => 1.75,
37        35 => 1.85,
38        53 => 1.98,
39        34 => 1.90,
40        _ => 1.70, // default fallback
41    }
42}
43
44/// Result of a SASA calculation.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct SasaResult {
47    /// Total solvent-accessible surface area in Ų.
48    pub total_sasa: f64,
49    /// Per-atom SASA values in Ų.
50    pub atom_sasa: Vec<f64>,
51    /// Probe radius used (Å).
52    pub probe_radius: f64,
53    /// Number of test points per sphere.
54    pub num_points: usize,
55}
56
57/// Generate approximately uniformly distributed points on a unit sphere using
58/// a golden-section spiral (Fibonacci sphere).
59fn generate_sphere_points(n: usize) -> Vec<[f64; 3]> {
60    let golden_ratio = (1.0 + 5.0_f64.sqrt()) / 2.0;
61    let angle_increment = 2.0 * PI / golden_ratio;
62
63    (0..n)
64        .map(|i| {
65            let y = 1.0 - (2.0 * i as f64) / (n as f64 - 1.0);
66            let radius = (1.0 - y * y).max(0.0).sqrt();
67            let theta = angle_increment * i as f64;
68            [radius * theta.cos(), y, radius * theta.sin()]
69        })
70        .collect()
71}
72
73fn compute_atom_sasa(
74    atom_index: usize,
75    positions: &[[f64; 3]],
76    radii: &[f64],
77    unit_points: &[[f64; 3]],
78) -> f64 {
79    let ri = radii[atom_index];
80    let pi = &positions[atom_index];
81    let npts = unit_points.len();
82    let area_per_point = 4.0 * PI * ri * ri / npts as f64;
83
84    let accessible = unit_points
85        .iter()
86        .filter(|pt| {
87            let test = [pi[0] + ri * pt[0], pi[1] + ri * pt[1], pi[2] + ri * pt[2]];
88
89            !positions.iter().enumerate().any(|(j, pos_j)| {
90                if j == atom_index {
91                    return false;
92                }
93                let rj = radii[j];
94                let dx = test[0] - pos_j[0];
95                let dy = test[1] - pos_j[1];
96                let dz = test[2] - pos_j[2];
97                let dist_sq = dx * dx + dy * dy + dz * dz;
98                dist_sq < rj * rj
99            })
100        })
101        .count();
102
103    accessible as f64 * area_per_point
104}
105
106/// Compute solvent-accessible surface area for a set of atoms.
107///
108/// # Arguments
109/// - `elements`: atomic numbers for each atom
110/// - `positions`: 3D coordinates in Å, one per atom
111/// - `probe_radius`: solvent probe radius in Å (default: 1.4 for water)
112/// - `num_points`: number of test points per atom sphere (default: 960)
113///
114/// # Returns
115/// `SasaResult` with total and per-atom SASA.
116pub fn compute_sasa(
117    elements: &[u8],
118    positions: &[[f64; 3]],
119    probe_radius: Option<f64>,
120    num_points: Option<usize>,
121) -> SasaResult {
122    let n = elements.len();
123    let probe = probe_radius.unwrap_or(DEFAULT_PROBE_RADIUS);
124    let npts = num_points.unwrap_or(DEFAULT_NUM_POINTS);
125
126    let unit_points = generate_sphere_points(npts);
127
128    // Precompute radii (vdw + probe)
129    let radii: Vec<f64> = elements.iter().map(|&z| vdw_radius(z) + probe).collect();
130
131    let atom_sasa: Vec<f64> = (0..n)
132        .map(|i| compute_atom_sasa(i, positions, &radii, &unit_points))
133        .collect();
134
135    let total_sasa: f64 = atom_sasa.iter().sum();
136
137    SasaResult {
138        total_sasa,
139        atom_sasa,
140        probe_radius: probe,
141        num_points: npts,
142    }
143}
144
145/// Compute SASA using rayon to evaluate atoms independently.
146#[cfg(feature = "parallel")]
147pub fn compute_sasa_parallel(
148    elements: &[u8],
149    positions: &[[f64; 3]],
150    probe_radius: Option<f64>,
151    num_points: Option<usize>,
152) -> SasaResult {
153    use rayon::prelude::*;
154
155    let probe = probe_radius.unwrap_or(DEFAULT_PROBE_RADIUS);
156    let npts = num_points.unwrap_or(DEFAULT_NUM_POINTS);
157    let unit_points = generate_sphere_points(npts);
158    let radii: Vec<f64> = elements.iter().map(|&z| vdw_radius(z) + probe).collect();
159
160    let atom_sasa: Vec<f64> = (0..elements.len())
161        .into_par_iter()
162        .map(|i| compute_atom_sasa(i, positions, &radii, &unit_points))
163        .collect();
164
165    let total_sasa: f64 = atom_sasa.iter().sum();
166
167    SasaResult {
168        total_sasa,
169        atom_sasa,
170        probe_radius: probe,
171        num_points: npts,
172    }
173}
174
175// ─── Tests ───────────────────────────────────────────────────────────────────
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_single_atom_sasa() {
183        // Single carbon atom: SASA should be approximately 4π(r_vdw + r_probe)²
184        let elems = vec![6];
185        let pos = vec![[0.0, 0.0, 0.0]];
186        let result = compute_sasa(&elems, &pos, Some(1.4), Some(2000));
187        let r = vdw_radius(6) + 1.4;
188        let expected = 4.0 * PI * r * r;
189        let error = (result.total_sasa - expected).abs() / expected;
190        assert!(
191            error < 0.02,
192            "Single atom SASA should be ~{:.1} Ų, got {:.1} (error {:.1}%)",
193            expected,
194            result.total_sasa,
195            error * 100.0
196        );
197    }
198
199    #[test]
200    fn test_two_distant_atoms() {
201        // Two atoms far apart: total SASA ≈ sum of individual SASAs
202        let elems = vec![6, 6];
203        let pos = vec![[0.0, 0.0, 0.0], [100.0, 0.0, 0.0]]; // very far apart
204        let result = compute_sasa(&elems, &pos, Some(1.4), Some(2000));
205        let r = vdw_radius(6) + 1.4;
206        let expected_per_atom = 4.0 * PI * r * r;
207        let expected_total = 2.0 * expected_per_atom;
208        let error = (result.total_sasa - expected_total).abs() / expected_total;
209        assert!(
210            error < 0.02,
211            "Far-apart atoms: expected {:.1}, got {:.1}",
212            expected_total,
213            result.total_sasa
214        );
215    }
216
217    #[test]
218    fn test_bonded_atoms_less_sasa() {
219        // Two carbon atoms at bonding distance: SASA < 2× individual
220        let elems = vec![6, 6];
221        let pos_far = vec![[0.0, 0.0, 0.0], [100.0, 0.0, 0.0]];
222        let pos_close = vec![[0.0, 0.0, 0.0], [1.54, 0.0, 0.0]]; // C-C bond ~1.54 Å
223        let far_result = compute_sasa(&elems, &pos_far, Some(1.4), Some(960));
224        let close_result = compute_sasa(&elems, &pos_close, Some(1.4), Some(960));
225        assert!(
226            close_result.total_sasa < far_result.total_sasa,
227            "Bonded atoms should have less SASA ({:.1}) than distant ({:.1})",
228            close_result.total_sasa,
229            far_result.total_sasa
230        );
231    }
232
233    #[test]
234    fn test_water_sasa() {
235        // H₂O: O at origin, 2 H at typical geometry
236        let elems = vec![8, 1, 1];
237        let pos = vec![[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
238        let result = compute_sasa(&elems, &pos, Some(1.4), Some(960));
239        // SASA for water should be reasonable (roughly 50–150 Ų depending on probe)
240        assert!(
241            result.total_sasa > 30.0 && result.total_sasa < 200.0,
242            "Water SASA should be reasonable: {:.1}",
243            result.total_sasa
244        );
245        assert_eq!(result.atom_sasa.len(), 3);
246    }
247
248    #[test]
249    fn test_zero_probe_radius() {
250        // With zero probe: gives van der Waals surface area
251        let elems = vec![6];
252        let pos = vec![[0.0, 0.0, 0.0]];
253        let result = compute_sasa(&elems, &pos, Some(0.0), Some(2000));
254        let r = vdw_radius(6);
255        let expected = 4.0 * PI * r * r;
256        let error = (result.total_sasa - expected).abs() / expected;
257        assert!(
258            error < 0.02,
259            "Zero-probe SASA should be vdW surface area: expected {:.1}, got {:.1}",
260            expected,
261            result.total_sasa
262        );
263    }
264
265    #[test]
266    fn test_sphere_points_distribution() {
267        // Points should be approximately uniformly distributed
268        let points = generate_sphere_points(1000);
269        assert_eq!(points.len(), 1000);
270        // Check all points are on the unit sphere
271        for p in &points {
272            let r = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
273            assert!(
274                (r - 1.0).abs() < 1e-10,
275                "Point should be on unit sphere: r={}",
276                r
277            );
278        }
279    }
280
281    #[test]
282    fn test_sasa_symmetry() {
283        // Two identical atoms equidistant from origin: should have same SASA
284        let elems = vec![6, 6];
285        let pos = vec![[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]];
286        let result = compute_sasa(&elems, &pos, None, None);
287        assert!(
288            (result.atom_sasa[0] - result.atom_sasa[1]).abs() < 0.5,
289            "Symmetric atoms should have similar SASA: {:.1} vs {:.1}",
290            result.atom_sasa[0],
291            result.atom_sasa[1]
292        );
293    }
294}