rust-sasa-python 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -243,6 +243,12 @@ dependencies = [
243
243
  "miniz_oxide",
244
244
  ]
245
245
 
246
+ [[package]]
247
+ name = "fnv"
248
+ version = "1.0.7"
249
+ source = "registry+https://github.com/rust-lang/crates.io-index"
250
+ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
251
+
246
252
  [[package]]
247
253
  name = "hash32"
248
254
  version = "0.3.1"
@@ -686,11 +692,12 @@ dependencies = [
686
692
 
687
693
  [[package]]
688
694
  name = "rust-sasa"
689
- version = "0.5.0"
695
+ version = "0.7.0"
690
696
  source = "registry+https://github.com/rust-lang/crates.io-index"
691
- checksum = "fdaeea886f82f71b433e7247c7734ffacd6d90efff66ae91f24ffd13cf0e786c"
697
+ checksum = "d92c8f75a02c33d1b3e3f233fa84b39cb127041c07ed77bbd0a25cd3fa07c1c5"
692
698
  dependencies = [
693
699
  "clap",
700
+ "fnv",
694
701
  "indicatif",
695
702
  "mimalloc",
696
703
  "nalgebra",
@@ -705,12 +712,13 @@ dependencies = [
705
712
 
706
713
  [[package]]
707
714
  name = "rust-sasa-python"
708
- version = "0.4.0"
715
+ version = "0.5.0"
709
716
  dependencies = [
710
717
  "mimalloc",
711
718
  "nalgebra",
712
719
  "num-complex",
713
720
  "pdbtbx",
721
+ "pulp",
714
722
  "pyo3",
715
723
  "rust-sasa",
716
724
  ]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "rust-sasa-python"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  edition = "2024"
5
5
  readme = "README.md"
6
6
 
@@ -12,7 +12,8 @@ crate-type = ["cdylib"]
12
12
  [dependencies]
13
13
  pdbtbx = "0.12.0"
14
14
  pyo3 = "0.25.1"
15
- rust-sasa = "0.5.0"
15
+ rust-sasa = "0.7.0"
16
16
  nalgebra = "0.33.2"
17
17
  mimalloc = "0.1.47"
18
18
  num-complex = "0.4.6"
19
+ pulp = "0.22.2"
@@ -34,7 +34,27 @@ SASACalculator(pdb_path: str)
34
34
  - Returns modified calculator for method chaining
35
35
 
36
36
  **`with_n_points(points: int) -> SASACalculator`**
37
- - Set the number of points for surface sampling (default: 1000)
37
+ - Set the number of points for surface sampling (default: 100)
38
+ - Returns modified calculator for method chaining
39
+
40
+ **`with_threads(threads: int) -> SASACalculator`**
41
+ - Set the number of threads for parallel processing (default: -1 uses all cores)
42
+ - Returns modified calculator for method chaining
43
+
44
+ **`with_include_hydrogens(include_hydrogens: bool) -> SASACalculator`**
45
+ - Set whether to include hydrogen atoms in the calculation (default: False)
46
+ - Returns modified calculator for method chaining
47
+
48
+ **`with_include_hetatms(include_hetatms: bool) -> SASACalculator`**
49
+ - Set whether to include HETATM records in the calculation (default: False)
50
+ - Returns modified calculator for method chaining
51
+
52
+ **`with_radii_file(path: str) -> SASACalculator`**
53
+ - Customize Van der Waals radii by providing a custom radii file (default: ProtOr by Tsai et al.)
54
+ - Returns modified calculator for method chaining
55
+
56
+ **`with_allow_vdw_fallback(allow: bool) -> SASACalculator`**
57
+ - Allow fallback to PDBTBX van der Waals radii when custom radius is not found (default: False)
38
58
  - Returns modified calculator for method chaining
39
59
 
40
60
  #### Calculation Methods
@@ -88,7 +108,7 @@ chain_results = sasa.calculate_chain_sasa("protein.pdb")
88
108
 
89
109
  For advanced use cases where you need to calculate SASA for custom atom sets:
90
110
 
91
- **`calculate_sasa_internal(atoms_in: list[tuple[tuple[float, float, float], float, int]], probe_radius: float, n_points: int) -> list[float]`**
111
+ **`calculate_sasa_internal(atoms_in: list[tuple[tuple[float, float, float], float, int]], probe_radius: float, n_points: int, threads: int) -> list[float]`**
92
112
 
93
113
  Calculate SASA for a set of atoms directly without PDB file parsing.
94
114
 
@@ -99,6 +119,7 @@ Calculate SASA for a set of atoms directly without PDB file parsing.
99
119
  - Atom index as int
100
120
  - `probe_radius`: Probe sphere radius in Ångströms
101
121
  - `n_points`: Number of points for surface sampling
122
+ - `threads`: Number of threads for parallel processing (-1 uses all cores)
102
123
 
103
124
  **Returns:**
104
125
  - List of SASA values (floats) corresponding to each input atom
@@ -115,13 +136,13 @@ atoms = [
115
136
  ]
116
137
 
117
138
  # Calculate SASA
118
- sasa_values = sasa.calculate_sasa_internal(atoms, probe_radius=1.4, n_points=100)
139
+ sasa_values = sasa.calculate_sasa_internal(atoms, probe_radius=1.4, n_points=100, threads=-1)
119
140
  print(f"SASA values: {sasa_values}")
120
141
  ```
121
142
 
122
- **`calculate_sasa_internal_at_residue_level(atoms_in: list[tuple[tuple[float, float, float], float, int]], probe_radius: float, n_points: int) -> list[ResidueResult]`**
143
+ **`calculate_sasa_internal_at_residue_level(atoms_in: list[tuple[tuple[float, float, float], float, int]], probe_radius: float, n_points: int, threads: int) -> list[ResidueResult]`**
123
144
 
124
- Calculate SASA at the residue level for a set of atoms directly without PDB file parsing. This function groups atoms by residue ID and calculates the average SASA per atom within each residue.
145
+ Calculate SASA at the residue level for a set of atoms directly without PDB file parsing. This function groups atoms by residue ID and calculates the total SASA for each residue.
125
146
 
126
147
  **Parameters:**
127
148
  - `atoms_in`: List of tuples, each containing:
@@ -130,13 +151,14 @@ Calculate SASA at the residue level for a set of atoms directly without PDB file
130
151
  - Residue ID as int (used to group atoms into residues)
131
152
  - `probe_radius`: Probe sphere radius in Ångströms
132
153
  - `n_points`: Number of points for surface sampling
154
+ - `threads`: Number of threads for parallel processing (-1 uses all cores)
133
155
 
134
156
  **Returns:**
135
157
  - List of `ResidueResult` objects with:
136
158
  - `chain_id`: Always "UNK" (unknown)
137
159
  - `residue_name`: Always "UNK" (unknown)
138
160
  - `residue_number`: The residue ID from input
139
- - `sasa`: Average SASA per atom in the residue
161
+ - `sasa`: Total SASA for the residue
140
162
 
141
163
  **Example:**
142
164
  ```python
@@ -151,9 +173,9 @@ atoms = [
151
173
  ]
152
174
 
153
175
  # Calculate residue-level SASA
154
- residues = sasa.calculate_sasa_internal_at_residue_level(atoms, probe_radius=1.4, n_points=100)
176
+ residues = sasa.calculate_sasa_internal_at_residue_level(atoms, probe_radius=1.4, n_points=100, threads=-1)
155
177
  for residue in residues:
156
- print(f"Residue {residue.residue_number}: {residue.sasa:.2f} Ų (avg per atom)")
178
+ print(f"Residue {residue.residue_number}: {residue.sasa:.2f} Ų")
157
179
  ```
158
180
 
159
181
  ## Usage Examples
@@ -235,6 +257,35 @@ for radius in radii:
235
257
  print(f"Radius {radius}: {result.total:.2f} Ų")
236
258
  ```
237
259
 
260
+ ### Advanced Configuration Examples
261
+
262
+ ```python
263
+ # Include hydrogen atoms in calculation
264
+ calc = (sasa.SASACalculator("protein.pdb")
265
+ .with_include_hydrogens(True)
266
+ .with_n_points(2000))
267
+ result = calc.calculate_protein()
268
+ print(f"SASA with hydrogens: {result.total:.2f} Ų")
269
+
270
+ # Include HETATM records (ligands, ions, water)
271
+ calc = (sasa.SASACalculator("protein.pdb")
272
+ .with_include_hetatms(True))
273
+ result = calc.calculate_protein()
274
+ print(f"SASA with HETATMs: {result.total:.2f} Ų")
275
+
276
+ # Control parallelization
277
+ calc = (sasa.SASACalculator("protein.pdb")
278
+ .with_threads(4)) # Use 4 threads
279
+ result = calc.calculate_protein()
280
+
281
+ # Use custom Van der Waals radii file
282
+ calc = (sasa.SASACalculator("protein.pdb")
283
+ .with_radii_file("custom_radii.txt")
284
+ .with_allow_vdw_fallback(True)) # Fallback to default if not found
285
+ result = calc.calculate_protein()
286
+ print(f"SASA with custom radii: {result.total:.2f} Ų")
287
+ ```
288
+
238
289
  ### Error Handling
239
290
 
240
291
  ```python
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rust-sasa-python
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Programming Language :: Python :: Implementation :: PyPy
@@ -3,7 +3,7 @@ use pdbtbx;
3
3
  use pyo3::exceptions::PyRuntimeError;
4
4
  use pyo3::prelude::*;
5
5
  use rust_sasa::options::SASAOptions;
6
- use rust_sasa::{Atom, SASAProcessor, calculate_sasa_internal as calculate_sasa_internal_internal};
6
+ use rust_sasa::{Atom, calculate_sasa_internal as calculate_sasa_internal_internal};
7
7
  use rust_sasa::{AtomLevel, ChainLevel, ProteinLevel, ResidueLevel};
8
8
  use simd::simd_sum; // As in long long
9
9
  use std::collections::HashMap;
@@ -79,6 +79,11 @@ pub struct SASACalculator {
79
79
  pdb_path: String,
80
80
  probe_radius: Option<f32>,
81
81
  n_points: Option<usize>,
82
+ radii_file: Option<String>,
83
+ threads: isize,
84
+ include_hydrogens: bool,
85
+ include_hetatms: bool,
86
+ allow_vdw_fallback: bool,
82
87
  }
83
88
 
84
89
  #[pymethods]
@@ -89,6 +94,11 @@ impl SASACalculator {
89
94
  pdb_path,
90
95
  probe_radius: None,
91
96
  n_points: None,
97
+ radii_file: None,
98
+ threads: -1,
99
+ include_hydrogens: false,
100
+ include_hetatms: false,
101
+ allow_vdw_fallback: false,
92
102
  }
93
103
  }
94
104
 
@@ -116,21 +126,73 @@ impl SASACalculator {
116
126
  self.clone()
117
127
  }
118
128
 
129
+ /// Set the number of threads for parallel processing
130
+ ///
131
+ /// Args:
132
+ /// threads: Number of threads (default: -1 uses all cores)
133
+ ///
134
+ /// Returns:
135
+ /// Self for method chaining
136
+ pub fn with_threads(&mut self, threads: isize) -> Self {
137
+ self.threads = threads;
138
+ self.clone()
139
+ }
140
+
141
+ /// Set whether to include hydrogens in the calculation
142
+ ///
143
+ /// Args:
144
+ /// include_hydrogens: Whether to include hydrogens (default: false)
145
+ ///
146
+ /// Returns:
147
+ /// Self for method chaining
148
+ pub fn with_include_hydrogens(&mut self, include_hydrogens: bool) -> Self {
149
+ self.include_hydrogens = include_hydrogens;
150
+ self.clone()
151
+ }
152
+
153
+ /// Set whether to include HETATM records in the calculation
154
+ ///
155
+ /// Args:
156
+ /// include_hetatms: Whether to include HETATM records (default: false)
157
+ ///
158
+ /// Returns:
159
+ /// Self for method chaining
160
+ pub fn with_include_hetatms(&mut self, include_hetatms: bool) -> Self {
161
+ self.include_hetatms = include_hetatms;
162
+ self.clone()
163
+ }
164
+
165
+ /// Customize Van der Waals radii file
166
+ ///
167
+ /// Args:
168
+ /// path: Path to the custom radii file (default: ProtOr by Tasi et al.)
169
+ ///
170
+ /// Returns:
171
+ /// Self for method chaining
172
+ pub fn with_radii_file(&mut self, path: &str) -> Self {
173
+ self.radii_file = Some(path.to_string());
174
+ self.clone()
175
+ }
176
+
177
+ /// Allow fallback to PDBTBX van der Waals radii when custom radius is not found (default: false)
178
+ ///
179
+ /// Args:
180
+ /// allow: Whether to allow fallback to PDBTBX van der Waals radii (default: false)
181
+ ///
182
+ /// Returns:
183
+ /// Self for method chaining
184
+ pub fn with_allow_vdw_fallback(&mut self, allow: bool) -> Self {
185
+ self.allow_vdw_fallback = allow;
186
+ self.clone()
187
+ }
188
+
119
189
  /// Calculate SASA at the protein level
120
190
  ///
121
191
  /// Returns:
122
192
  /// Protein: Object containing total, polar, and non-polar SASA values
123
193
  pub fn calculate_protein(&self) -> PyResult<Protein> {
124
194
  let pdb = self.load_pdb()?;
125
- let mut options = SASAOptions::<ProteinLevel>::new();
126
-
127
- if let Some(radius) = self.probe_radius {
128
- options = options.with_probe_radius(radius);
129
- }
130
-
131
- if let Some(points) = self.n_points {
132
- options = options.with_n_points(points);
133
- }
195
+ let options = self.apply_options(SASAOptions::<ProteinLevel>::new())?;
134
196
 
135
197
  match options.process(&pdb) {
136
198
  Ok(result) => Ok(Protein {
@@ -151,15 +213,7 @@ impl SASACalculator {
151
213
  /// List[Chain]: List of chain-level SASA results
152
214
  pub fn calculate_chain(&self) -> PyResult<Vec<Chain>> {
153
215
  let pdb = self.load_pdb()?;
154
- let mut options = SASAOptions::<ChainLevel>::new();
155
-
156
- if let Some(radius) = self.probe_radius {
157
- options = options.with_probe_radius(radius);
158
- }
159
-
160
- if let Some(points) = self.n_points {
161
- options = options.with_n_points(points);
162
- }
216
+ let options = self.apply_options(SASAOptions::<ChainLevel>::new())?;
163
217
 
164
218
  match options.process(&pdb) {
165
219
  Ok(results) => {
@@ -185,15 +239,7 @@ impl SASACalculator {
185
239
  /// List[Residue]: List of residue-level SASA results
186
240
  pub fn calculate_residue(&self) -> PyResult<Vec<Residue>> {
187
241
  let pdb = self.load_pdb()?;
188
- let mut options = SASAOptions::<ResidueLevel>::new();
189
-
190
- if let Some(radius) = self.probe_radius {
191
- options = options.with_probe_radius(radius);
192
- }
193
-
194
- if let Some(points) = self.n_points {
195
- options = options.with_n_points(points);
196
- }
242
+ let options = self.apply_options(SASAOptions::<ResidueLevel>::new())?;
197
243
 
198
244
  match options.process(&pdb) {
199
245
  Ok(results) => {
@@ -221,15 +267,7 @@ impl SASACalculator {
221
267
  /// List[float]: List of atom-level SASA values
222
268
  pub fn calculate_atom(&self) -> PyResult<Vec<f32>> {
223
269
  let pdb = self.load_pdb()?;
224
- let mut options = SASAOptions::<AtomLevel>::new();
225
-
226
- if let Some(radius) = self.probe_radius {
227
- options = options.with_probe_radius(radius);
228
- }
229
-
230
- if let Some(points) = self.n_points {
231
- options = options.with_n_points(points);
232
- }
270
+ let options = self.apply_options(SASAOptions::<AtomLevel>::new())?;
233
271
 
234
272
  match options.process(&pdb) {
235
273
  Ok(results) => Ok(results),
@@ -273,6 +311,29 @@ impl SASACalculator {
273
311
  ))),
274
312
  }
275
313
  }
314
+
315
+ fn apply_options<T>(&self, mut options: SASAOptions<T>) -> PyResult<SASAOptions<T>> {
316
+ if let Some(radius) = self.probe_radius {
317
+ options = options.with_probe_radius(radius);
318
+ }
319
+
320
+ if let Some(points) = self.n_points {
321
+ options = options.with_n_points(points);
322
+ }
323
+
324
+ options = options.with_threads(self.threads);
325
+ options = options.with_include_hydrogens(self.include_hydrogens);
326
+ options = options.with_include_hetatms(self.include_hetatms);
327
+ options = options.with_allow_vdw_fallback(self.allow_vdw_fallback);
328
+
329
+ if let Some(radii_file) = &self.radii_file {
330
+ options = options.with_radii_file(radii_file).map_err(|e| {
331
+ PyRuntimeError::new_err(format!("Failed to load radii file: {}", e))
332
+ })?;
333
+ }
334
+
335
+ Ok(options)
336
+ }
276
337
  }
277
338
 
278
339
  // Convenience functions for backward compatibility and simple use cases
@@ -305,6 +366,7 @@ pub fn calculate_sasa_internal(
305
366
  atoms_in: Vec<((f32, f32, f32), f32, usize)>,
306
367
  probe_radius: f32,
307
368
  n_points: usize,
369
+ threads: isize,
308
370
  ) -> PyResult<Vec<f32>> {
309
371
  let atoms: Vec<Atom> = atoms_in
310
372
  .into_iter()
@@ -320,7 +382,7 @@ pub fn calculate_sasa_internal(
320
382
  atoms.as_slice(),
321
383
  probe_radius,
322
384
  n_points,
323
- true,
385
+ threads,
324
386
  ))
325
387
  }
326
388
 
@@ -329,8 +391,10 @@ fn calculate_sasa_internal_at_residue_level(
329
391
  atoms_in: Vec<((f32, f32, f32), f32, usize)>,
330
392
  probe_radius: f32,
331
393
  n_points: usize,
394
+ threads: isize,
332
395
  ) -> PyResult<Vec<Residue>> {
333
- let atom_sasa = calculate_sasa_internal(atoms_in.clone(), probe_radius, n_points).unwrap();
396
+ let atom_sasa =
397
+ calculate_sasa_internal(atoms_in.clone(), probe_radius, n_points, threads).unwrap();
334
398
 
335
399
  let mut residue_groups: HashMap<usize, Vec<f32>> = HashMap::new();
336
400
 
@@ -0,0 +1,15 @@
1
+ use std::sync::LazyLock;
2
+
3
+ use pulp::Arch;
4
+
5
+ pub(crate) static ARCH: LazyLock<Arch> = LazyLock::new(Arch::new);
6
+
7
+ pub(crate) fn simd_sum(values: &[f32]) -> f32 {
8
+ let mut total = 0f32;
9
+ ARCH.dispatch(|| {
10
+ for x in values {
11
+ total += x;
12
+ }
13
+ });
14
+ total
15
+ }
@@ -0,0 +1,381 @@
1
+ import time
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ import rust_sasa_python as sasa
7
+
8
+ # Path to the example CIF file
9
+ EXAMPLE_CIF = Path(__file__).parent.parent / "example.cif"
10
+
11
+
12
+ def test_protein_calculation():
13
+ """Test protein-level SASA calculation."""
14
+ calculator = sasa.SASACalculator(str(EXAMPLE_CIF))
15
+ result = calculator.calculate_protein()
16
+
17
+ assert isinstance(result, sasa.Protein)
18
+ assert result.total > 0
19
+ assert result.polar >= 0
20
+ assert result.non_polar >= 0
21
+ assert abs(result.total - (result.polar + result.non_polar)) < 1.0
22
+
23
+
24
+ def test_chain_calculation():
25
+ """Test chain-level SASA calculation."""
26
+ calculator = sasa.SASACalculator(str(EXAMPLE_CIF))
27
+ results = calculator.calculate_chain()
28
+
29
+ assert isinstance(results, list)
30
+ assert len(results) > 0
31
+
32
+ for chain in results:
33
+ assert isinstance(chain, sasa.Chain)
34
+ assert isinstance(chain.chain_id, str)
35
+ assert chain.sasa >= 0
36
+
37
+
38
+ def test_residue_calculation():
39
+ """Test residue-level SASA calculation."""
40
+ calculator = sasa.SASACalculator(str(EXAMPLE_CIF))
41
+ results = calculator.calculate_residue()
42
+
43
+ assert isinstance(results, list)
44
+ assert len(results) > 0
45
+
46
+ for residue in results:
47
+ assert isinstance(residue, sasa.Residue)
48
+ assert isinstance(residue.chain_id, str)
49
+ assert isinstance(residue.residue_name, str)
50
+ assert isinstance(residue.residue_number, int)
51
+ assert residue.sasa >= 0
52
+
53
+
54
+ def test_atom_calculation():
55
+ """Test atom-level SASA calculation."""
56
+ calculator = sasa.SASACalculator(str(EXAMPLE_CIF))
57
+ results = calculator.calculate_atom()
58
+
59
+ assert isinstance(results, list)
60
+ assert len(results) > 0
61
+
62
+ for atom_sasa in results:
63
+ assert isinstance(atom_sasa, float)
64
+ assert atom_sasa >= 0
65
+
66
+
67
+ def test_builder_pattern():
68
+ """Test configuration using builder pattern."""
69
+ calculator = sasa.SASACalculator(str(EXAMPLE_CIF)).with_probe_radius(1.4).with_n_points(500)
70
+ result = calculator.calculate_protein()
71
+
72
+ assert result.total > 0
73
+ assert "probe_radius=1.4" in repr(calculator)
74
+ assert "n_points=500" in repr(calculator)
75
+
76
+
77
+ def test_different_configurations():
78
+ """Test that different configurations give different results."""
79
+ calc1 = sasa.SASACalculator(str(EXAMPLE_CIF)).with_probe_radius(1.2)
80
+ calc2 = sasa.SASACalculator(str(EXAMPLE_CIF)).with_probe_radius(1.6)
81
+
82
+ result1 = calc1.calculate_protein()
83
+ result2 = calc2.calculate_protein()
84
+
85
+ # Different probe radii should give different results
86
+ assert result1.total != result2.total
87
+
88
+
89
+ def test_convenience_functions():
90
+ """Test all convenience functions work."""
91
+ protein = sasa.calculate_protein_sasa(str(EXAMPLE_CIF))
92
+ chains = sasa.calculate_chain_sasa(str(EXAMPLE_CIF))
93
+ residues = sasa.calculate_residue_sasa(str(EXAMPLE_CIF))
94
+ atoms = sasa.calculate_atom_sasa(str(EXAMPLE_CIF))
95
+
96
+ assert isinstance(protein, sasa.Protein)
97
+ assert isinstance(chains, list) and len(chains) > 0
98
+ assert isinstance(residues, list) and len(residues) > 0
99
+ assert isinstance(atoms, list) and len(atoms) > 0
100
+
101
+
102
+ def test_consistency_between_granularities():
103
+ """Test that different granularities give consistent totals."""
104
+ calculator = sasa.SASACalculator(str(EXAMPLE_CIF)).with_n_points(500)
105
+
106
+ protein_result = calculator.calculate_protein()
107
+ chain_results = calculator.calculate_chain()
108
+ residue_results = calculator.calculate_residue()
109
+ atom_results = calculator.calculate_atom()
110
+
111
+ # Sum of parts should approximately equal whole
112
+ chain_total = sum(chain.sasa for chain in chain_results)
113
+ residue_total = sum(residue.sasa for residue in residue_results)
114
+ atom_total = sum(atom_results)
115
+
116
+ tolerance = 0.1
117
+ assert abs(protein_result.total - chain_total) < tolerance
118
+ assert abs(protein_result.total - residue_total) < tolerance
119
+ assert abs(protein_result.total - atom_total) < tolerance
120
+
121
+
122
+ def test_convenience_vs_calculator():
123
+ """Test that convenience functions give same results as calculator."""
124
+ # Using calculator
125
+ calculator = sasa.SASACalculator(str(EXAMPLE_CIF))
126
+ calc_protein = calculator.calculate_protein()
127
+
128
+ # Using convenience function
129
+ conv_protein = sasa.calculate_protein_sasa(str(EXAMPLE_CIF))
130
+
131
+ # Results should be identical
132
+ assert abs(calc_protein.total - conv_protein.total) < 0.001
133
+
134
+
135
+ def test_nonexistent_file():
136
+ """Test handling of nonexistent file."""
137
+ with pytest.raises(Exception):
138
+ calculator = sasa.SASACalculator("nonexistent_file.pdb")
139
+ calculator.calculate_protein()
140
+
141
+
142
+ def test_calculation_speed():
143
+ """Test that calculations complete quickly."""
144
+ calculator = sasa.SASACalculator(str(EXAMPLE_CIF)).with_n_points(1000)
145
+
146
+ start_time = time.time()
147
+ result = calculator.calculate_protein()
148
+ end_time = time.time()
149
+
150
+ calculation_time = end_time - start_time
151
+ assert calculation_time < 2.0 # Should be very fast
152
+ assert result.total > 0
153
+
154
+
155
+ def test_repr_methods():
156
+ """Test that repr methods work for all objects."""
157
+ calculator = sasa.SASACalculator(str(EXAMPLE_CIF))
158
+
159
+ # Test calculator repr
160
+ assert "SASACalculator(" in repr(calculator)
161
+
162
+ # Test result reprs
163
+ protein = calculator.calculate_protein()
164
+ chains = calculator.calculate_chain()
165
+ residues = calculator.calculate_residue()
166
+
167
+ assert "Protein(" in repr(protein)
168
+ if chains:
169
+ assert "Chain(" in repr(chains[0])
170
+ if residues:
171
+ assert "Residue(" in repr(residues[0])
172
+
173
+
174
+ def test_workflow_example():
175
+ """Test a realistic workflow."""
176
+ # Quick calculation
177
+ quick_result = sasa.calculate_protein_sasa(str(EXAMPLE_CIF))
178
+ assert quick_result.total > 0
179
+
180
+ # Detailed analysis
181
+ calculator = sasa.SASACalculator(str(EXAMPLE_CIF)).with_probe_radius(1.4).with_n_points(500)
182
+
183
+ protein = calculator.calculate_protein()
184
+ chains = calculator.calculate_chain()
185
+ residues = calculator.calculate_residue()
186
+
187
+ # All should be valid
188
+ assert protein.total > 0
189
+ assert len(chains) >= 1
190
+ assert len(residues) >= 1
191
+
192
+ # Should have expected structure
193
+ if residues:
194
+ first_residue = residues[0]
195
+ assert hasattr(first_residue, "chain_id")
196
+ assert hasattr(first_residue, "residue_name")
197
+ assert hasattr(first_residue, "residue_number")
198
+ assert first_residue.sasa >= 0
199
+
200
+
201
+ def test_with_threads():
202
+ """Test thread configuration."""
203
+ # Test with different thread counts
204
+ calc_single = sasa.SASACalculator(str(EXAMPLE_CIF)).with_threads(1)
205
+ calc_multi = sasa.SASACalculator(str(EXAMPLE_CIF)).with_threads(4)
206
+ calc_auto = sasa.SASACalculator(str(EXAMPLE_CIF)).with_threads(-1)
207
+
208
+ result_single = calc_single.calculate_protein()
209
+ result_multi = calc_multi.calculate_protein()
210
+ result_auto = calc_auto.calculate_protein()
211
+
212
+ # Results should be identical regardless of thread count
213
+ assert abs(result_single.total - result_multi.total) < 0.01
214
+ assert abs(result_single.total - result_auto.total) < 0.01
215
+
216
+
217
+ def test_with_include_hydrogens():
218
+ """Test hydrogen inclusion configuration."""
219
+ calc_no_h = sasa.SASACalculator(str(EXAMPLE_CIF)).with_include_hydrogens(False)
220
+ calc_with_h = sasa.SASACalculator(str(EXAMPLE_CIF)).with_include_hydrogens(True)
221
+
222
+ result_no_h = calc_no_h.calculate_protein()
223
+ result_with_h = calc_with_h.calculate_protein()
224
+
225
+ # Both should return valid results
226
+ assert result_no_h.total > 0
227
+ assert result_with_h.total > 0
228
+
229
+ # Atom counts should differ if hydrogens are present
230
+ atoms_no_h = calc_no_h.calculate_atom()
231
+ atoms_with_h = calc_with_h.calculate_atom()
232
+
233
+ assert len(atoms_no_h) > 0
234
+ assert len(atoms_with_h) > 0
235
+
236
+
237
+ def test_with_include_hetatms():
238
+ """Test HETATM inclusion configuration."""
239
+ calc_no_het = sasa.SASACalculator(str(EXAMPLE_CIF)).with_include_hetatms(False)
240
+ calc_with_het = sasa.SASACalculator(str(EXAMPLE_CIF)).with_include_hetatms(True)
241
+
242
+ result_no_het = calc_no_het.calculate_protein()
243
+ result_with_het = calc_with_het.calculate_protein()
244
+
245
+ # Both should return valid results
246
+ assert result_no_het.total > 0
247
+ assert result_with_het.total > 0
248
+
249
+ # Atom counts may differ if HETATMs are present
250
+ atoms_no_het = calc_no_het.calculate_atom()
251
+ atoms_with_het = calc_with_het.calculate_atom()
252
+
253
+ assert len(atoms_no_het) > 0
254
+ assert len(atoms_with_het) > 0
255
+
256
+
257
+ def test_with_allow_vdw_fallback():
258
+ """Test VDW fallback configuration."""
259
+ # Test that fallback can be enabled/disabled
260
+ calc_no_fallback = sasa.SASACalculator(str(EXAMPLE_CIF)).with_allow_vdw_fallback(False)
261
+ calc_with_fallback = sasa.SASACalculator(str(EXAMPLE_CIF)).with_allow_vdw_fallback(True)
262
+
263
+ result_no_fallback = calc_no_fallback.calculate_protein()
264
+ result_with_fallback = calc_with_fallback.calculate_protein()
265
+
266
+ # Both should return valid results for standard PDB files
267
+ assert result_no_fallback.total > 0
268
+ assert result_with_fallback.total > 0
269
+
270
+
271
+ def test_multiple_config_chaining():
272
+ """Test chaining multiple configuration methods."""
273
+ calculator = (
274
+ sasa.SASACalculator(str(EXAMPLE_CIF))
275
+ .with_probe_radius(1.4)
276
+ .with_n_points(1000)
277
+ .with_threads(2)
278
+ .with_include_hydrogens(False)
279
+ .with_include_hetatms(False)
280
+ .with_allow_vdw_fallback(True)
281
+ )
282
+
283
+ # Should be able to calculate at all levels
284
+ protein = calculator.calculate_protein()
285
+ chains = calculator.calculate_chain()
286
+ residues = calculator.calculate_residue()
287
+ atoms = calculator.calculate_atom()
288
+
289
+ assert protein.total > 0
290
+ assert len(chains) > 0
291
+ assert len(residues) > 0
292
+ assert len(atoms) > 0
293
+
294
+
295
+ def test_probe_radius_variations():
296
+ """Test various probe radius values."""
297
+ radii = [1.0, 1.2, 1.4, 1.6, 1.8, 2.0]
298
+ results = []
299
+
300
+ for radius in radii:
301
+ calc = sasa.SASACalculator(str(EXAMPLE_CIF)).with_probe_radius(radius)
302
+ result = calc.calculate_protein()
303
+ results.append(result.total)
304
+
305
+ # All results should be positive
306
+ assert all(r > 0 for r in results)
307
+ # Larger probe radii should generally give smaller SASA values
308
+ # (larger probe can't access smaller crevices)
309
+ assert results[-1] < results[0]
310
+
311
+
312
+ def test_n_points_variations():
313
+ """Test various n_points values for accuracy."""
314
+ points = [50, 100, 500, 1000]
315
+ results = []
316
+
317
+ for n in points:
318
+ calc = sasa.SASACalculator(str(EXAMPLE_CIF)).with_n_points(n)
319
+ result = calc.calculate_protein()
320
+ results.append(result.total)
321
+
322
+ # Higher point counts should give more consistent results
323
+ assert all(r > 0 for r in results)
324
+ # Results should converge (difference between high point counts should be small)
325
+ assert abs(results[-1] - results[-2]) < abs(results[1] - results[0])
326
+
327
+
328
+ def test_calculate_sasa_internal_with_threads():
329
+ """Test low-level calculate_sasa_internal function with threads parameter."""
330
+ # Define simple atom set
331
+ atoms = [
332
+ ((0.0, 0.0, 0.0), 1.7, 0),
333
+ ((2.0, 0.0, 0.0), 1.2, 1),
334
+ ((0.0, 2.0, 0.0), 1.55, 2),
335
+ ]
336
+
337
+ # Test with different thread counts
338
+ sasa_single = sasa.calculate_sasa_internal(atoms, 1.4, 100, 1)
339
+ sasa_multi = sasa.calculate_sasa_internal(atoms, 1.4, 100, 4)
340
+ sasa_auto = sasa.calculate_sasa_internal(atoms, 1.4, 100, -1)
341
+
342
+ # Should return same number of values
343
+ assert len(sasa_single) == len(atoms)
344
+ assert len(sasa_multi) == len(atoms)
345
+ assert len(sasa_auto) == len(atoms)
346
+
347
+ # Results should be nearly identical
348
+ for i in range(len(atoms)):
349
+ assert abs(sasa_single[i] - sasa_multi[i]) < 0.01
350
+ assert abs(sasa_single[i] - sasa_auto[i]) < 0.01
351
+
352
+
353
+ def test_calculate_sasa_internal_at_residue_level_with_threads():
354
+ """Test low-level residue calculation with threads parameter."""
355
+ # Define atoms grouped by residue
356
+ atoms = [
357
+ ((0.0, 0.0, 0.0), 1.7, 1),
358
+ ((1.5, 0.0, 0.0), 1.2, 1),
359
+ ((0.0, 3.0, 0.0), 1.55, 2),
360
+ ]
361
+
362
+ # Test with different thread counts
363
+ residues_single = sasa.calculate_sasa_internal_at_residue_level(atoms, 1.4, 100, 1)
364
+ residues_multi = sasa.calculate_sasa_internal_at_residue_level(atoms, 1.4, 100, 4)
365
+ residues_auto = sasa.calculate_sasa_internal_at_residue_level(atoms, 1.4, 100, -1)
366
+
367
+ # Should return same number of residues
368
+ assert len(residues_single) == 2
369
+ assert len(residues_multi) == 2
370
+ assert len(residues_auto) == 2
371
+
372
+ # Sort by residue_number to ensure consistent ordering
373
+ residues_single = sorted(residues_single, key=lambda r: r.residue_number)
374
+ residues_multi = sorted(residues_multi, key=lambda r: r.residue_number)
375
+ residues_auto = sorted(residues_auto, key=lambda r: r.residue_number)
376
+
377
+ # Results should be nearly identical when matched by residue_number
378
+ for r1, r2, r3 in zip(residues_single, residues_multi, residues_auto):
379
+ assert r1.residue_number == r2.residue_number == r3.residue_number
380
+ assert abs(r1.sasa - r2.sasa) < 0.01
381
+ assert abs(r1.sasa - r3.sasa) < 0.01
@@ -1,33 +0,0 @@
1
- const LANES: usize = 16;
2
- #[allow(clippy::needless_range_loop)]
3
- pub fn simd_sum(values: &[f32]) -> f32 {
4
- let chunks = values.chunks_exact(LANES);
5
- let remainder = chunks.remainder();
6
-
7
- let sum = chunks.fold([0.0f32; LANES], |mut acc, chunk| {
8
- let chunk: [f32; LANES] = chunk.try_into().unwrap();
9
- for i in 0..LANES {
10
- acc[i] += chunk[i];
11
- }
12
- acc
13
- });
14
-
15
- let remainder: f32 = remainder.iter().copied().sum();
16
-
17
- let mut reduced = 0.0f32;
18
- for i in 0..LANES {
19
- reduced += sum[i];
20
- }
21
- reduced + remainder
22
- }
23
-
24
- pub(crate) fn serialize_chain_id(s: &str) -> isize {
25
- let mut result = 0;
26
- for c in s.chars() {
27
- if c.is_ascii_alphabetic() {
28
- let position = c.to_ascii_uppercase() as isize - 64;
29
- result = result * 10 + position;
30
- }
31
- }
32
- result
33
- }
@@ -1,198 +0,0 @@
1
- import time
2
- from pathlib import Path
3
-
4
- import pytest
5
-
6
- import rust_sasa_python as sasa
7
-
8
- # Path to the example CIF file
9
- EXAMPLE_CIF = Path(__file__).parent.parent / "example.cif"
10
-
11
-
12
- def test_protein_calculation():
13
- """Test protein-level SASA calculation."""
14
- calculator = sasa.SASACalculator(str(EXAMPLE_CIF))
15
- result = calculator.calculate_protein()
16
-
17
- assert isinstance(result, sasa.Protein)
18
- assert result.total > 0
19
- assert result.polar >= 0
20
- assert result.non_polar >= 0
21
- assert abs(result.total - (result.polar + result.non_polar)) < 1.0
22
-
23
-
24
- def test_chain_calculation():
25
- """Test chain-level SASA calculation."""
26
- calculator = sasa.SASACalculator(str(EXAMPLE_CIF))
27
- results = calculator.calculate_chain()
28
-
29
- assert isinstance(results, list)
30
- assert len(results) > 0
31
-
32
- for chain in results:
33
- assert isinstance(chain, sasa.Chain)
34
- assert isinstance(chain.chain_id, str)
35
- assert chain.sasa >= 0
36
-
37
-
38
- def test_residue_calculation():
39
- """Test residue-level SASA calculation."""
40
- calculator = sasa.SASACalculator(str(EXAMPLE_CIF))
41
- results = calculator.calculate_residue()
42
-
43
- assert isinstance(results, list)
44
- assert len(results) > 0
45
-
46
- for residue in results:
47
- assert isinstance(residue, sasa.Residue)
48
- assert isinstance(residue.chain_id, str)
49
- assert isinstance(residue.residue_name, str)
50
- assert isinstance(residue.residue_number, int)
51
- assert residue.sasa >= 0
52
-
53
-
54
- def test_atom_calculation():
55
- """Test atom-level SASA calculation."""
56
- calculator = sasa.SASACalculator(str(EXAMPLE_CIF))
57
- results = calculator.calculate_atom()
58
-
59
- assert isinstance(results, list)
60
- assert len(results) > 0
61
-
62
- for atom_sasa in results:
63
- assert isinstance(atom_sasa, float)
64
- assert atom_sasa >= 0
65
-
66
-
67
- def test_builder_pattern():
68
- """Test configuration using builder pattern."""
69
- calculator = sasa.SASACalculator(str(EXAMPLE_CIF)).with_probe_radius(1.4).with_n_points(500)
70
- result = calculator.calculate_protein()
71
-
72
- assert result.total > 0
73
- assert "probe_radius=1.4" in repr(calculator)
74
- assert "n_points=500" in repr(calculator)
75
-
76
-
77
- def test_different_configurations():
78
- """Test that different configurations give different results."""
79
- calc1 = sasa.SASACalculator(str(EXAMPLE_CIF)).with_probe_radius(1.2)
80
- calc2 = sasa.SASACalculator(str(EXAMPLE_CIF)).with_probe_radius(1.6)
81
-
82
- result1 = calc1.calculate_protein()
83
- result2 = calc2.calculate_protein()
84
-
85
- # Different probe radii should give different results
86
- assert result1.total != result2.total
87
-
88
-
89
- def test_convenience_functions():
90
- """Test all convenience functions work."""
91
- protein = sasa.calculate_protein_sasa(str(EXAMPLE_CIF))
92
- chains = sasa.calculate_chain_sasa(str(EXAMPLE_CIF))
93
- residues = sasa.calculate_residue_sasa(str(EXAMPLE_CIF))
94
- atoms = sasa.calculate_atom_sasa(str(EXAMPLE_CIF))
95
-
96
- assert isinstance(protein, sasa.Protein)
97
- assert isinstance(chains, list) and len(chains) > 0
98
- assert isinstance(residues, list) and len(residues) > 0
99
- assert isinstance(atoms, list) and len(atoms) > 0
100
-
101
-
102
- def test_consistency_between_granularities():
103
- """Test that different granularities give consistent totals."""
104
- calculator = sasa.SASACalculator(str(EXAMPLE_CIF)).with_n_points(500)
105
-
106
- protein_result = calculator.calculate_protein()
107
- chain_results = calculator.calculate_chain()
108
- residue_results = calculator.calculate_residue()
109
- atom_results = calculator.calculate_atom()
110
-
111
- # Sum of parts should approximately equal whole
112
- chain_total = sum(chain.sasa for chain in chain_results)
113
- residue_total = sum(residue.sasa for residue in residue_results)
114
- atom_total = sum(atom_results)
115
-
116
- tolerance = 0.1
117
- assert abs(protein_result.total - chain_total) < tolerance
118
- assert abs(protein_result.total - residue_total) < tolerance
119
- assert abs(protein_result.total - atom_total) < tolerance
120
-
121
-
122
- def test_convenience_vs_calculator():
123
- """Test that convenience functions give same results as calculator."""
124
- # Using calculator
125
- calculator = sasa.SASACalculator(str(EXAMPLE_CIF))
126
- calc_protein = calculator.calculate_protein()
127
-
128
- # Using convenience function
129
- conv_protein = sasa.calculate_protein_sasa(str(EXAMPLE_CIF))
130
-
131
- # Results should be identical
132
- assert abs(calc_protein.total - conv_protein.total) < 0.001
133
-
134
-
135
- def test_nonexistent_file():
136
- """Test handling of nonexistent file."""
137
- with pytest.raises(Exception):
138
- calculator = sasa.SASACalculator("nonexistent_file.pdb")
139
- calculator.calculate_protein()
140
-
141
-
142
- def test_calculation_speed():
143
- """Test that calculations complete quickly."""
144
- calculator = sasa.SASACalculator(str(EXAMPLE_CIF)).with_n_points(1000)
145
-
146
- start_time = time.time()
147
- result = calculator.calculate_protein()
148
- end_time = time.time()
149
-
150
- calculation_time = end_time - start_time
151
- assert calculation_time < 2.0 # Should be very fast
152
- assert result.total > 0
153
-
154
-
155
- def test_repr_methods():
156
- """Test that repr methods work for all objects."""
157
- calculator = sasa.SASACalculator(str(EXAMPLE_CIF))
158
-
159
- # Test calculator repr
160
- assert "SASACalculator(" in repr(calculator)
161
-
162
- # Test result reprs
163
- protein = calculator.calculate_protein()
164
- chains = calculator.calculate_chain()
165
- residues = calculator.calculate_residue()
166
-
167
- assert "Protein(" in repr(protein)
168
- if chains:
169
- assert "Chain(" in repr(chains[0])
170
- if residues:
171
- assert "Residue(" in repr(residues[0])
172
-
173
-
174
- def test_workflow_example():
175
- """Test a realistic workflow."""
176
- # Quick calculation
177
- quick_result = sasa.calculate_protein_sasa(str(EXAMPLE_CIF))
178
- assert quick_result.total > 0
179
-
180
- # Detailed analysis
181
- calculator = sasa.SASACalculator(str(EXAMPLE_CIF)).with_probe_radius(1.4).with_n_points(500)
182
-
183
- protein = calculator.calculate_protein()
184
- chains = calculator.calculate_chain()
185
- residues = calculator.calculate_residue()
186
-
187
- # All should be valid
188
- assert protein.total > 0
189
- assert len(chains) >= 1
190
- assert len(residues) >= 1
191
-
192
- # Should have expected structure
193
- if residues:
194
- first_residue = residues[0]
195
- assert hasattr(first_residue, "chain_id")
196
- assert hasattr(first_residue, "residue_name")
197
- assert hasattr(first_residue, "residue_number")
198
- assert first_residue.sasa >= 0