1use serde::{Deserialize, Serialize};
10use std::f64::consts::PI;
11
12pub const DEFAULT_PROBE_RADIUS: f64 = 1.4;
14
15pub const DEFAULT_NUM_POINTS: usize = 960;
18
19pub 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, }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct SasaResult {
47 pub total_sasa: f64,
49 pub atom_sasa: Vec<f64>,
51 pub probe_radius: f64,
53 pub num_points: usize,
55}
56
57fn 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
106pub 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 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#[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#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn test_single_atom_sasa() {
183 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 let elems = vec![6, 6];
203 let pos = vec![[0.0, 0.0, 0.0], [100.0, 0.0, 0.0]]; 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 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]]; 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 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 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 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 let points = generate_sphere_points(1000);
269 assert_eq!(points.len(), 1000);
270 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 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}