Coverage for src / molecular_simulations / analysis / sasa.py: 99%
91 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-12 10:07 -0600
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-12 10:07 -0600
1import MDAnalysis as mda
2from MDAnalysis.analysis.base import AnalysisBase
3from MDAnalysis.core import groups
4from MDAnalysis.guesser.tables import vdwradii
5import numpy as np
6from scipy.spatial import KDTree
7import warnings
9warnings.filterwarnings('ignore')
11class SASA(AnalysisBase):
12 """
13 Computes solvent-accessible surface area using the Shrake-Rupley algorithm.
14 Code is adapted from the biopython and mdtraj implementations as well as an
15 unmerged PR from MDAnalysis. Implemented as an instance of AnalysisBase to
16 support ease of deployment and baked-in parallelism.
18 Arguments:
19 ag (mda.AtomGroup): AtomGroup for which to perform SASA.
20 probe_radius (float): Defaults to 1.4Å. This is pretty standard and should not
21 be altered in most cases.
22 n_points (int): Defaults to 256. Number of points to place on each sphere, where
23 a higher number is more costly but accurate.
24 """
25 def __init__(self,
26 ag: mda.AtomGroup,
27 probe_radius: float=1.4,
28 n_points: int=256,
29 **kwargs):
30 if isinstance(ag, groups.UpdatingAtomGroup):
31 raise TypeError('UpdatingAtomGroups are not valid for SASA!')
33 super(SASA, self).__init__(ag.universe.trajectory, **kwargs)
35 if not hasattr(ag, 'elements'):
36 raise ValueError('Cannot assign atomic radii:'
37 'Universe has no `elements` property!')
39 self.ag = ag
40 self.probe_radius = probe_radius
41 self.n_points = n_points
43 self.radii_dict = dict()
44 self.radii_dict.update(vdwradii)
46 self.radii = np.vectorize(self.radii_dict.get)(self.ag.elements)
47 self.radii += self.probe_radius
48 self.max_radii = 2 * np.max(self.radii)
50 self.sphere = self.get_sphere()
52 def get_sphere(self) -> np.ndarray:
53 """
54 Returns a fibonacci unit sphere for a given number of points.
56 Returns:
57 (np.ndarray): Array of points in fibonacci sphere.
58 """
59 dl = np.pi * (3 - np.sqrt(5))
60 dz = 2. / self.n_points
61 longitude = 0
62 z = 1 - dz / 2
64 xyz = np.zeros((self.n_points, 3), dtype=np.float32)
65 for i in range(self.n_points):
66 r = np.sqrt(1 - z**2)
67 xyz[i, :] = [np.cos(longitude) * r, np.sin(longitude) * r, z]
69 z -= dz
70 longitude += dl
72 return xyz
74 def measure_sasa(self,
75 ag: mda.AtomGroup) -> float:
76 """
77 Measures the SASA of input atomgroup.
79 Arguments:
80 ag (mda.AtomGroup): MDAnalysis AtomGroup.
82 Returns:
83 (float): SASA value.
84 """
85 kdt = KDTree(ag.positions, 10)
87 points = np.zeros(ag.n_atoms)
88 for i in range(ag.n_atoms):
89 sphere = self.sphere.copy() * self.radii[i]
90 sphere += ag.positions[i]
91 available = self.points_available.copy()
92 kdt_sphere = KDTree(sphere, 10)
94 for j in kdt.query_ball_point(ag.positions[i],
95 self.max_radii,
96 workers=-1):
97 if j == i:
98 continue
99 if self.radii[j] < (self.radii[i] + self.radii[j]):
100 available -= {
101 n for n in kdt_sphere.query_ball_point(
102 self.ag.positions[j],
103 self.radii[j]
104 )
105 }
107 points[i] = len(available)
109 return 4 * np.pi * self.radii**2 * points / self.n_points
111 def _prepare(self):
112 """
113 Preprocessing step that is run before analyzing the trajectory.
114 Initializes the results array.
116 Returns:
117 None
118 """
119 self.results.sasa = np.zeros(self.ag.n_residues)
121 self.points_available = set(range(self.n_points))
123 def _single_frame(self):
124 """
125 Operations called on each frame. Measures SASA for each atom and sums
126 them per-residue, placing the results into internal output array.
128 Returns:
129 None
130 """
131 area = self.measure_sasa(self.ag)
132 result = np.zeros(self.ag.n_residues)
133 for i, atom in enumerate(self.ag.atoms):
134 result[atom.resid - 1] += area[i]
136 self.results.sasa += result
138 def _conclude(self):
139 """
140 Post-processing step to average the SASA per-frame.
142 Returns:
143 None
144 """
145 if self.n_frames != 0:
146 self.results.sasa /= self.n_frames
148class RelativeSASA(SASA):
149 """
150 Computese Relative SASA for a specified atomgroup. Relative SASA is defined
151 by the measured SASA divided by the maximum accessible surface area and for
152 proteins we define this as within a tripeptide context so as to not overestimate
153 SASA of the amide linkage and its neighbors.
155 Arguments:
156 ag (mda.AtomGroup): AtomGroup for which to perform SASA.
157 probe_radius (float): Defaults to 1.4Å. This is pretty standard and should not
158 be altered in most cases.
159 n_points (int): Defaults to 256. Number of points to place on each sphere, where
160 a higher number is more costly but accurate.
161 """
162 def __init__(self,
163 ag: mda.AtomGroup,
164 probe_radius: float=1.4,
165 n_points: int=256,
166 **kwargs):
167 if not hasattr(ag, 'bonds'):
168 raise ValueError('Universe has no `bonds` property!')
169 super(RelativeSASA, self).__init__(ag, probe_radius, n_points, **kwargs)
171 def _prepare(self):
172 """
173 Preprocessing step that is run before analyzing the trajectory.
174 Initializes the results array.
176 Returns:
177 None
178 """
179 self.results.sasa = np.zeros(self.ag.n_residues)
180 self.results.relative_area = np.zeros(self.ag.n_residues)
182 self.points_available = set(range(self.n_points))
184 def _single_frame(self):
185 """
186 Operations called on each frame. Measures SASA for each atom and sums
187 them per-residue. Then computes the exposed area of each tripeptide
188 and stores the ratio of SASA / exposed area in output array.
190 Returns:
191 None
192 """
193 area = self.measure_sasa(self.ag)
194 result = np.zeros(self.ag.n_residues)
195 for i, atom in enumerate(self.ag.atoms):
196 result[atom.resid - 1] += area[i]
198 self.results.sasa += result
200 for res_index in self.ag.residues.resindices:
201 tri_peptide = self.ag.select_atoms(f'byres (bonded resindex {res_index})')
203 if len(tri_peptide) == 0:
204 continue
206 tri_pep_area = self.measure_sasa(tri_peptide)
207 exposed_area = sum([
208 a for a, _id in zip(tri_pep_area, tri_peptide.resindices)
209 if _id == res_index
210 ])
212 if exposed_area != 0.:
213 result[res_index] /= exposed_area
215 self.results.relative_area += np.array([result[_id] for _id in self.ag.residues.resindices])
217 def _conclude(self):
218 """
219 Post-processing step to average the SASA per-frame.
221 Returns:
222 None
223 """
224 if self.n_frames != 0:
225 self.results.sasa /= self.n_frames
226 self.results.relative_area /= self.n_frames