zsasa

zsasa: Fast SASA calculation using Zig.

This package provides Python bindings for the zsasa library, a high-performance implementation of Solvent Accessible Surface Area (SASA) calculation algorithms.

Example:

import numpy as np from zsasa import calculate_sasa

Single atom

coords = np.array([[0.0, 0.0, 0.0]]) radii = np.array([1.5]) result = calculate_sasa(coords, radii) print(f"Total SASA: {result.total_area:.2f} Ų")

>>> # Classify atoms
>>> from zsasa import classify_atoms, get_radius
>>> result = classify_atoms(["ALA", "ALA"], ["CA", "O"])
>>> print(result.radii)  # [1.87, 1.4]

Integrations: For structure file support, use the gemmi integration:

>>> # pip install zsasa[gemmi]
>>> from zsasa.integrations.gemmi import calculate_sasa_from_structure
>>> result = calculate_sasa_from_structure("protein.cif")
>>> print(f"Total: {result.total_area:.1f} Ų")

Analysis: For per-residue aggregation and RSA calculation:

>>> from zsasa import aggregate_from_result
>>> from zsasa.integrations.gemmi import calculate_sasa_from_structure
>>> result = calculate_sasa_from_structure("protein.cif")
>>> residues = aggregate_from_result(result)
>>> for res in residues:
...     if res.rsa is not None:
...         print(f"{res.chain_id}:{res.residue_name}{res.residue_id}: RSA={res.rsa:.1%}")

MDTraj Integration: For MD trajectory analysis (requires mdtraj):

>>> # pip install mdtraj
>>> from zsasa.mdtraj import compute_sasa
>>> import mdtraj as md
>>> traj = md.load('trajectory.xtc', top='topology.pdb')
>>> sasa = compute_sasa(traj)  # Returns (n_frames, n_atoms) in nm²

MDAnalysis Integration: For MD trajectory analysis with MDAnalysis (requires MDAnalysis):

>>> # pip install MDAnalysis
>>> import MDAnalysis as mda
>>> from zsasa.mdanalysis import SASAAnalysis
>>> u = mda.Universe('topology.pdb', 'trajectory.xtc')
>>> sasa = SASAAnalysis(u, select='protein')
>>> sasa.run()
>>> print(sasa.results.total_area)  # Returns per-frame SASA in Ų

Trajectory File I/O: For direct trajectory-file I/O and trajectory-native analysis in Python, prefer pyztraj (the Python bindings for ztraj). zsasa keeps the legacy zsasa.xtc and zsasa.dcd modules for compatibility, but new trajectory formats are centralized in pyztraj.

>>> # pip install pyztraj
>>> import pyztraj
>>> structure = pyztraj.load_pdb("topology.pdb")
>>> with pyztraj.open_trr("trajectory.trr", structure.n_atoms) as reader:
...     for frame in reader:
...         sasa = pyztraj.compute_sasa(structure, frame.coords)
...         print(sasa.total_area)
  1"""zsasa: Fast SASA calculation using Zig.
  2
  3This package provides Python bindings for the zsasa library,
  4a high-performance implementation of Solvent Accessible Surface Area (SASA)
  5calculation algorithms.
  6
  7Example:
  8    >>> import numpy as np
  9    >>> from zsasa import calculate_sasa
 10    >>>
 11    >>> # Single atom
 12    >>> coords = np.array([[0.0, 0.0, 0.0]])
 13    >>> radii = np.array([1.5])
 14    >>> result = calculate_sasa(coords, radii)
 15    >>> print(f"Total SASA: {result.total_area:.2f} Ų")
 16
 17    >>> # Classify atoms
 18    >>> from zsasa import classify_atoms, get_radius
 19    >>> result = classify_atoms(["ALA", "ALA"], ["CA", "O"])
 20    >>> print(result.radii)  # [1.87, 1.4]
 21
 22Integrations:
 23    For structure file support, use the gemmi integration:
 24
 25    >>> # pip install zsasa[gemmi]
 26    >>> from zsasa.integrations.gemmi import calculate_sasa_from_structure
 27    >>> result = calculate_sasa_from_structure("protein.cif")
 28    >>> print(f"Total: {result.total_area:.1f} Ų")
 29
 30Analysis:
 31    For per-residue aggregation and RSA calculation:
 32
 33    >>> from zsasa import aggregate_from_result
 34    >>> from zsasa.integrations.gemmi import calculate_sasa_from_structure
 35    >>> result = calculate_sasa_from_structure("protein.cif")
 36    >>> residues = aggregate_from_result(result)
 37    >>> for res in residues:
 38    ...     if res.rsa is not None:
 39    ...         print(f"{res.chain_id}:{res.residue_name}{res.residue_id}: RSA={res.rsa:.1%}")
 40
 41MDTraj Integration:
 42    For MD trajectory analysis (requires mdtraj):
 43
 44    >>> # pip install mdtraj
 45    >>> from zsasa.mdtraj import compute_sasa
 46    >>> import mdtraj as md
 47    >>> traj = md.load('trajectory.xtc', top='topology.pdb')
 48    >>> sasa = compute_sasa(traj)  # Returns (n_frames, n_atoms) in nm²
 49
 50MDAnalysis Integration:
 51    For MD trajectory analysis with MDAnalysis (requires MDAnalysis):
 52
 53    >>> # pip install MDAnalysis
 54    >>> import MDAnalysis as mda
 55    >>> from zsasa.mdanalysis import SASAAnalysis
 56    >>> u = mda.Universe('topology.pdb', 'trajectory.xtc')
 57    >>> sasa = SASAAnalysis(u, select='protein')
 58    >>> sasa.run()
 59    >>> print(sasa.results.total_area)  # Returns per-frame SASA in Ų
 60
 61Trajectory File I/O:
 62    For direct trajectory-file I/O and trajectory-native analysis in Python,
 63    prefer pyztraj (the Python bindings for ztraj). zsasa keeps the legacy
 64    zsasa.xtc and zsasa.dcd modules for compatibility, but new trajectory
 65    formats are centralized in pyztraj.
 66
 67    >>> # pip install pyztraj
 68    >>> import pyztraj
 69    >>> structure = pyztraj.load_pdb("topology.pdb")
 70    >>> with pyztraj.open_trr("trajectory.trr", structure.n_atoms) as reader:
 71    ...     for frame in reader:
 72    ...         sasa = pyztraj.compute_sasa(structure, frame.coords)
 73    ...         print(sasa.total_area)
 74"""
 75
 76from zsasa._ffi import get_version
 77from zsasa.analysis import (
 78    ResidueResult,
 79    aggregate_by_residue,
 80    aggregate_from_result,
 81)
 82from zsasa.batch import BatchDirResult, process_directory
 83from zsasa.classifier import (
 84    AtomClass,
 85    ClassificationResult,
 86    ClassifierType,
 87    classify_atoms,
 88    get_atom_class,
 89    get_radius,
 90    guess_radius,
 91    guess_radius_from_atom_name,
 92)
 93from zsasa.rsa import (
 94    MAX_SASA,
 95    calculate_rsa,
 96    calculate_rsa_batch,
 97    get_max_sasa,
 98)
 99from zsasa.sasa import (
100    BatchSasaResult,
101    SasaResult,
102    calculate_sasa,
103    calculate_sasa_batch,
104)
105
106__all__ = [
107    # SASA calculation
108    "calculate_sasa",
109    "calculate_sasa_batch",
110    "SasaResult",
111    "BatchSasaResult",
112    # Batch directory processing
113    "process_directory",
114    "BatchDirResult",
115    # Classifier
116    "ClassifierType",
117    "AtomClass",
118    "ClassificationResult",
119    "get_radius",
120    "get_atom_class",
121    "guess_radius",
122    "guess_radius_from_atom_name",
123    "classify_atoms",
124    # RSA
125    "MAX_SASA",
126    "get_max_sasa",
127    "calculate_rsa",
128    "calculate_rsa_batch",
129    # Analysis
130    "ResidueResult",
131    "aggregate_by_residue",
132    "aggregate_from_result",
133    # Utility
134    "get_version",
135]
136
137__version__ = get_version()
def calculate_sasa( coords: NDArray[numpy.float64], radii: NDArray[numpy.float64], *, algorithm: Literal['sr', 'lr'] = 'sr', n_points: int = 100, n_slices: int = 20, probe_radius: float = 1.4, n_threads: int = 0, use_bitmask: bool = False, bitmask_correction: bool = False, bitmask_correction_coeff: float | None = None) -> SasaResult:
106def calculate_sasa(
107    coords: NDArray[np.float64],
108    radii: NDArray[np.float64],
109    *,
110    algorithm: Literal["sr", "lr"] = "sr",
111    n_points: int = 100,
112    n_slices: int = 20,
113    probe_radius: float = 1.4,
114    n_threads: int = 0,
115    use_bitmask: bool = False,
116    bitmask_correction: bool = False,
117    bitmask_correction_coeff: float | None = None,
118) -> SasaResult:
119    """Calculate Solvent Accessible Surface Area (SASA).
120
121    Args:
122        coords: Atom coordinates as (N, 3) array.
123        radii: Atom radii as (N,) array.
124        algorithm: Algorithm to use: "sr" (Shrake-Rupley) or "lr" (Lee-Richards).
125        n_points: Number of test points per atom (for SR algorithm). Default: 100.
126        n_slices: Number of slices per atom (for LR algorithm). Default: 20.
127        probe_radius: Water probe radius in Angstroms. Default: 1.4.
128        n_threads: Number of threads to use. 0 = auto-detect. Default: 0.
129        use_bitmask: Use bitmask LUT optimization for SR algorithm.
130            Supports n_points 1..1024. Default: False.
131        bitmask_correction: Apply experimental bitmask exposed-fraction
132            correction. Requires use_bitmask=True. Default: False.
133        bitmask_correction_coeff: Optional non-negative correction coefficient.
134            Defaults to the library's experimental coefficient when omitted.
135
136    Returns:
137        SasaResult containing total_area and per-atom atom_areas.
138
139    Raises:
140        ValueError: If input arrays have invalid shapes or calculation fails.
141
142    Example:
143        >>> import numpy as np
144        >>> from zsasa import calculate_sasa
145        >>>
146        >>> # Two atoms
147        >>> coords = np.array([[0.0, 0.0, 0.0], [3.0, 0.0, 0.0]])
148        >>> radii = np.array([1.5, 1.5])
149        >>> result = calculate_sasa(coords, radii)
150        >>> print(f"Total: {result.total_area:.2f}")
151    """
152    # Validate parameters before loading the C library so invalid Python inputs
153    # never reach CFFI conversion or native code.
154    n_points = _validate_uint32_param("n_points", n_points)
155    n_slices = _validate_uint32_param("n_slices", n_slices)
156    probe_radius = _validate_positive_finite_float("probe_radius", probe_radius)
157    n_threads = _validate_size_t_param("n_threads", n_threads)
158    if bitmask_correction and not use_bitmask:
159        msg = "bitmask_correction=True requires use_bitmask=True"
160        raise ValueError(msg)
161    if bitmask_correction_coeff is not None and (
162        bitmask_correction_coeff < 0 or not np.isfinite(bitmask_correction_coeff)
163    ):
164        msg = f"bitmask_correction_coeff must be non-negative, got {bitmask_correction_coeff}"
165        raise ValueError(msg)
166
167    # Validate bitmask constraints
168    if use_bitmask:
169        use_bitmask = _validate_bitmask_params(algorithm, n_points, strict=bitmask_correction)
170
171    # Validate and convert inputs
172    coords = np.ascontiguousarray(coords, dtype=np.float64)
173    radii = np.ascontiguousarray(radii, dtype=np.float64)
174
175    if coords.ndim != 2 or coords.shape[1] != 3:
176        msg = f"coords must be (N, 3) array, got shape {coords.shape}"
177        raise ValueError(msg)
178
179    n_atoms = coords.shape[0]
180    if radii.shape != (n_atoms,):
181        msg = f"radii must be ({n_atoms},) array, got shape {radii.shape}"
182        raise ValueError(msg)
183
184    _validate_finite_array("coords", coords)
185    _validate_finite_array("radii", radii)
186
187    if np.any(radii < 0):
188        msg = "All radii must be non-negative"
189        raise ValueError(msg)
190
191    ffi, lib = _get_lib()
192
193    # Extract x, y, z as contiguous arrays
194    x = np.ascontiguousarray(coords[:, 0])
195    y = np.ascontiguousarray(coords[:, 1])
196    z = np.ascontiguousarray(coords[:, 2])
197
198    # Allocate output arrays
199    atom_areas = np.zeros(n_atoms, dtype=np.float64)
200    total_area = ffi.new("double*")
201
202    # Get cffi pointers from numpy arrays
203    x_ptr = ffi.cast("double*", x.ctypes.data)
204    y_ptr = ffi.cast("double*", y.ctypes.data)
205    z_ptr = ffi.cast("double*", z.ctypes.data)
206    radii_ptr = ffi.cast("double*", radii.ctypes.data)
207    areas_ptr = ffi.cast("double*", atom_areas.ctypes.data)
208
209    # Call the appropriate function
210    if use_bitmask:
211        if bitmask_correction:
212            coeff = 0.020 if bitmask_correction_coeff is None else bitmask_correction_coeff
213            result = lib.zsasa_calc_sr_bitmask_corrected(
214                x_ptr,
215                y_ptr,
216                z_ptr,
217                radii_ptr,
218                n_atoms,
219                n_points,
220                probe_radius,
221                n_threads,
222                coeff,
223                areas_ptr,
224                total_area,
225            )
226        else:
227            result = lib.zsasa_calc_sr_bitmask(
228                x_ptr,
229                y_ptr,
230                z_ptr,
231                radii_ptr,
232                n_atoms,
233                n_points,
234                probe_radius,
235                n_threads,
236                areas_ptr,
237                total_area,
238            )
239    elif algorithm == "sr":
240        result = lib.zsasa_calc_sr(
241            x_ptr,
242            y_ptr,
243            z_ptr,
244            radii_ptr,
245            n_atoms,
246            n_points,
247            probe_radius,
248            n_threads,
249            areas_ptr,
250            total_area,
251        )
252    elif algorithm == "lr":
253        result = lib.zsasa_calc_lr(
254            x_ptr,
255            y_ptr,
256            z_ptr,
257            radii_ptr,
258            n_atoms,
259            n_slices,
260            probe_radius,
261            n_threads,
262            areas_ptr,
263            total_area,
264        )
265    else:
266        msg = f"Unknown algorithm: {algorithm}. Use 'sr' or 'lr'."
267        raise ValueError(msg)
268
269    # Check for errors
270    if result == ZSASA_ERROR_INVALID_INPUT:
271        msg = "Invalid input to SASA calculation"
272        raise ValueError(msg)
273    elif result == ZSASA_ERROR_OUT_OF_MEMORY:
274        msg = "Out of memory during SASA calculation"
275        raise MemoryError(msg)
276    elif result == ZSASA_ERROR_CALCULATION:
277        msg = "Error during SASA calculation"
278        raise RuntimeError(msg)
279    elif result == ZSASA_ERROR_UNSUPPORTED_N_POINTS:
280        msg = (
281            f"Unsupported n_points for bitmask: {n_points}. "
282            f"Must be {_BITMASK_MIN_N_POINTS}..{_BITMASK_MAX_N_POINTS}"
283        )
284        raise ValueError(msg)
285    elif result != ZSASA_OK:
286        msg = f"Unknown error code: {result}"
287        raise RuntimeError(msg)
288
289    return SasaResult(
290        total_area=total_area[0],
291        atom_areas=atom_areas,
292    )

Calculate Solvent Accessible Surface Area (SASA).

Args: coords: Atom coordinates as (N, 3) array. radii: Atom radii as (N,) array. algorithm: Algorithm to use: "sr" (Shrake-Rupley) or "lr" (Lee-Richards). n_points: Number of test points per atom (for SR algorithm). Default: 100. n_slices: Number of slices per atom (for LR algorithm). Default: 20. probe_radius: Water probe radius in Angstroms. Default: 1.4. n_threads: Number of threads to use. 0 = auto-detect. Default: 0. use_bitmask: Use bitmask LUT optimization for SR algorithm. Supports n_points 1..1024. Default: False. bitmask_correction: Apply experimental bitmask exposed-fraction correction. Requires use_bitmask=True. Default: False. bitmask_correction_coeff: Optional non-negative correction coefficient. Defaults to the library's experimental coefficient when omitted.

Returns: SasaResult containing total_area and per-atom atom_areas.

Raises: ValueError: If input arrays have invalid shapes or calculation fails.

Example:

import numpy as np from zsasa import calculate_sasa

Two atoms

coords = np.array([[0.0, 0.0, 0.0], [3.0, 0.0, 0.0]]) radii = np.array([1.5, 1.5]) result = calculate_sasa(coords, radii) print(f"Total: {result.total_area:.2f}")

def calculate_sasa_batch( coordinates: NDArray[numpy.floating], radii: NDArray[numpy.floating], *, algorithm: Literal['sr', 'lr'] = 'sr', n_points: int = 100, n_slices: int = 20, probe_radius: float = 1.4, n_threads: int = 0, precision: Literal['f64', 'f32'] = 'f64', use_bitmask: bool = False, bitmask_correction: bool = False, bitmask_correction_coeff: float | None = None) -> BatchSasaResult:
325def calculate_sasa_batch(
326    coordinates: NDArray[np.floating],
327    radii: NDArray[np.floating],
328    *,
329    algorithm: Literal["sr", "lr"] = "sr",
330    n_points: int = 100,
331    n_slices: int = 20,
332    probe_radius: float = 1.4,
333    n_threads: int = 0,
334    precision: Literal["f64", "f32"] = "f64",
335    use_bitmask: bool = False,
336    bitmask_correction: bool = False,
337    bitmask_correction_coeff: float | None = None,
338) -> BatchSasaResult:
339    """Calculate SASA for multiple frames (batch processing).
340
341    Optimized for MD trajectory analysis where the same atoms are processed
342    across multiple frames. Parallelizes across frames for maximum performance.
343
344    Args:
345        coordinates: Atom coordinates as (n_frames, n_atoms, 3) array in Angstroms.
346        radii: Atom radii as (n_atoms,) array in Angstroms.
347        algorithm: Algorithm to use: "sr" (Shrake-Rupley) or "lr" (Lee-Richards).
348        n_points: Number of test points per atom (for SR algorithm). Default: 100.
349        n_slices: Number of slices per atom (for LR algorithm). Default: 20.
350        probe_radius: Water probe radius in Angstroms. Default: 1.4.
351        n_threads: Number of threads to use. 0 = auto-detect. Default: 0.
352        precision: Internal calculation precision: "f64" (default, higher precision)
353            or "f32" (matches RustSASA/mdsasa-bolt for comparison). Default: "f64".
354        use_bitmask: Use bitmask LUT optimization for SR algorithm.
355            Supports n_points 1..1024. Default: False.
356        bitmask_correction: Apply experimental bitmask exposed-fraction
357            correction. Requires use_bitmask=True. Default: False.
358        bitmask_correction_coeff: Optional non-negative correction coefficient.
359            Defaults to the library's experimental coefficient when omitted.
360
361    Returns:
362        BatchSasaResult containing per-atom SASA for all frames.
363
364    Raises:
365        ValueError: If input arrays have invalid shapes or calculation fails.
366
367    Example:
368        >>> import numpy as np
369        >>> from zsasa import calculate_sasa_batch
370        >>>
371        >>> # 10 frames, 100 atoms
372        >>> coords = np.random.randn(10, 100, 3).astype(np.float32)
373        >>> radii = np.full(100, 1.5, dtype=np.float32)
374        >>> result = calculate_sasa_batch(coords, radii)
375        >>> print(f"Shape: {result.atom_areas.shape}")  # (10, 100)
376        >>> print(f"Total SASA per frame: {result.total_areas}")
377    """
378    # Validate parameters before loading the C library so invalid Python inputs
379    # never reach CFFI conversion or native code.
380    n_points = _validate_uint32_param("n_points", n_points)
381    n_slices = _validate_uint32_param("n_slices", n_slices)
382    probe_radius = _validate_positive_finite_float("probe_radius", probe_radius)
383    n_threads = _validate_size_t_param("n_threads", n_threads)
384    if bitmask_correction and not use_bitmask:
385        msg = "bitmask_correction=True requires use_bitmask=True"
386        raise ValueError(msg)
387    if bitmask_correction_coeff is not None and (
388        bitmask_correction_coeff < 0 or not np.isfinite(bitmask_correction_coeff)
389    ):
390        msg = f"bitmask_correction_coeff must be non-negative, got {bitmask_correction_coeff}"
391        raise ValueError(msg)
392
393    # Validate bitmask constraints
394    if use_bitmask:
395        use_bitmask = _validate_bitmask_params(algorithm, n_points, strict=bitmask_correction)
396
397    # Validate and convert inputs
398    coordinates = np.ascontiguousarray(coordinates, dtype=np.float32)
399    radii = np.ascontiguousarray(radii, dtype=np.float32)
400
401    if coordinates.ndim != 3 or coordinates.shape[2] != 3:
402        msg = f"coordinates must be (n_frames, n_atoms, 3) array, got shape {coordinates.shape}"
403        raise ValueError(msg)
404
405    n_frames = coordinates.shape[0]
406    n_atoms = coordinates.shape[1]
407
408    if radii.shape != (n_atoms,):
409        msg = f"radii must be ({n_atoms},) array, got shape {radii.shape}"
410        raise ValueError(msg)
411
412    _validate_finite_array("coordinates", coordinates)
413    _validate_finite_array("radii", radii)
414
415    if np.any(radii < 0):
416        msg = "All radii must be non-negative"
417        raise ValueError(msg)
418
419    ffi, lib = _get_lib()
420
421    # Allocate output array
422    atom_areas = np.zeros((n_frames, n_atoms), dtype=np.float32)
423
424    # Get cffi pointers from numpy arrays
425    coords_ptr = ffi.cast("float*", coordinates.ctypes.data)
426    radii_ptr = ffi.cast("float*", radii.ctypes.data)
427    areas_ptr = ffi.cast("float*", atom_areas.ctypes.data)
428
429    # Call the appropriate batch function based on algorithm, precision, and bitmask
430    if use_bitmask:
431        coeff = 0.020 if bitmask_correction_coeff is None else bitmask_correction_coeff
432        if precision == "f32":
433            if bitmask_correction:
434                result = lib.zsasa_calc_sr_batch_bitmask_f32_corrected(
435                    coords_ptr,
436                    n_frames,
437                    n_atoms,
438                    radii_ptr,
439                    n_points,
440                    probe_radius,
441                    n_threads,
442                    coeff,
443                    areas_ptr,
444                )
445            else:
446                result = lib.zsasa_calc_sr_batch_bitmask_f32(
447                    coords_ptr,
448                    n_frames,
449                    n_atoms,
450                    radii_ptr,
451                    n_points,
452                    probe_radius,
453                    n_threads,
454                    areas_ptr,
455                )
456        else:
457            if bitmask_correction:
458                result = lib.zsasa_calc_sr_batch_bitmask_corrected(
459                    coords_ptr,
460                    n_frames,
461                    n_atoms,
462                    radii_ptr,
463                    n_points,
464                    probe_radius,
465                    n_threads,
466                    coeff,
467                    areas_ptr,
468                )
469            else:
470                result = lib.zsasa_calc_sr_batch_bitmask(
471                    coords_ptr,
472                    n_frames,
473                    n_atoms,
474                    radii_ptr,
475                    n_points,
476                    probe_radius,
477                    n_threads,
478                    areas_ptr,
479                )
480    elif precision == "f64":
481        # Default: f32 I/O with f64 internal precision
482        if algorithm == "sr":
483            result = lib.zsasa_calc_sr_batch(
484                coords_ptr,
485                n_frames,
486                n_atoms,
487                radii_ptr,
488                n_points,
489                probe_radius,
490                n_threads,
491                areas_ptr,
492            )
493        elif algorithm == "lr":
494            result = lib.zsasa_calc_lr_batch(
495                coords_ptr,
496                n_frames,
497                n_atoms,
498                radii_ptr,
499                n_slices,
500                probe_radius,
501                n_threads,
502                areas_ptr,
503            )
504        else:
505            msg = f"Unknown algorithm: {algorithm}. Use 'sr' or 'lr'."
506            raise ValueError(msg)
507    elif precision == "f32":
508        # Pure f32 precision (matches RustSASA/mdsasa-bolt)
509        if algorithm == "sr":
510            result = lib.zsasa_calc_sr_batch_f32(
511                coords_ptr,
512                n_frames,
513                n_atoms,
514                radii_ptr,
515                n_points,
516                probe_radius,
517                n_threads,
518                areas_ptr,
519            )
520        elif algorithm == "lr":
521            result = lib.zsasa_calc_lr_batch_f32(
522                coords_ptr,
523                n_frames,
524                n_atoms,
525                radii_ptr,
526                n_slices,
527                probe_radius,
528                n_threads,
529                areas_ptr,
530            )
531        else:
532            msg = f"Unknown algorithm: {algorithm}. Use 'sr' or 'lr'."
533            raise ValueError(msg)
534    else:
535        msg = f"Unknown precision: {precision}. Use 'f64' or 'f32'."
536        raise ValueError(msg)
537
538    # Check for errors
539    if result == ZSASA_ERROR_INVALID_INPUT:
540        msg = "Invalid input to batch SASA calculation"
541        raise ValueError(msg)
542    elif result == ZSASA_ERROR_OUT_OF_MEMORY:
543        msg = "Out of memory during batch SASA calculation"
544        raise MemoryError(msg)
545    elif result == ZSASA_ERROR_CALCULATION:
546        msg = "Error during batch SASA calculation"
547        raise RuntimeError(msg)
548    elif result == ZSASA_ERROR_UNSUPPORTED_N_POINTS:
549        msg = (
550            f"Unsupported n_points for bitmask: {n_points}. "
551            f"Must be {_BITMASK_MIN_N_POINTS}..{_BITMASK_MAX_N_POINTS}"
552        )
553        raise ValueError(msg)
554    elif result != ZSASA_OK:
555        msg = f"Unknown error code: {result}"
556        raise RuntimeError(msg)
557
558    return BatchSasaResult(atom_areas=atom_areas)

Calculate SASA for multiple frames (batch processing).

Optimized for MD trajectory analysis where the same atoms are processed across multiple frames. Parallelizes across frames for maximum performance.

Args: coordinates: Atom coordinates as (n_frames, n_atoms, 3) array in Angstroms. radii: Atom radii as (n_atoms,) array in Angstroms. algorithm: Algorithm to use: "sr" (Shrake-Rupley) or "lr" (Lee-Richards). n_points: Number of test points per atom (for SR algorithm). Default: 100. n_slices: Number of slices per atom (for LR algorithm). Default: 20. probe_radius: Water probe radius in Angstroms. Default: 1.4. n_threads: Number of threads to use. 0 = auto-detect. Default: 0. precision: Internal calculation precision: "f64" (default, higher precision) or "f32" (matches RustSASA/mdsasa-bolt for comparison). Default: "f64". use_bitmask: Use bitmask LUT optimization for SR algorithm. Supports n_points 1..1024. Default: False. bitmask_correction: Apply experimental bitmask exposed-fraction correction. Requires use_bitmask=True. Default: False. bitmask_correction_coeff: Optional non-negative correction coefficient. Defaults to the library's experimental coefficient when omitted.

Returns: BatchSasaResult containing per-atom SASA for all frames.

Raises: ValueError: If input arrays have invalid shapes or calculation fails.

Example:

import numpy as np from zsasa import calculate_sasa_batch

10 frames, 100 atoms

coords = np.random.randn(10, 100, 3).astype(np.float32) radii = np.full(100, 1.5, dtype=np.float32) result = calculate_sasa_batch(coords, radii) print(f"Shape: {result.atom_areas.shape}") # (10, 100) print(f"Total SASA per frame: {result.total_areas}")

@dataclass
class SasaResult:
 93@dataclass
 94class SasaResult:
 95    """Result of SASA calculation.
 96
 97    Attributes:
 98        total_area: Total solvent accessible surface area in Ų.
 99        atom_areas: Per-atom SASA values in Ų.
100    """
101
102    total_area: float
103    atom_areas: NDArray[np.float64]

Result of SASA calculation.

Attributes: total_area: Total solvent accessible surface area in Ų. atom_areas: Per-atom SASA values in Ų.

SasaResult(total_area: float, atom_areas: NDArray[numpy.float64])
total_area: float
atom_areas: NDArray[numpy.float64]
@dataclass
class BatchSasaResult:
295@dataclass
296class BatchSasaResult:
297    """Result of batch SASA calculation for multiple frames.
298
299    Attributes:
300        atom_areas: Per-atom SASA values for all frames, shape (n_frames, n_atoms).
301                    Values are in Angstrom² (Ų).
302    """
303
304    atom_areas: NDArray[np.float32]
305
306    @property
307    def n_frames(self) -> int:
308        """Number of frames."""
309        return self.atom_areas.shape[0]
310
311    @property
312    def n_atoms(self) -> int:
313        """Number of atoms."""
314        return self.atom_areas.shape[1]
315
316    @property
317    def total_areas(self) -> NDArray[np.float32]:
318        """Total SASA per frame, shape (n_frames,)."""
319        return self.atom_areas.sum(axis=1)
320
321    def __repr__(self) -> str:
322        return f"BatchSasaResult(n_frames={self.n_frames}, n_atoms={self.n_atoms})"

Result of batch SASA calculation for multiple frames.

Attributes: atom_areas: Per-atom SASA values for all frames, shape (n_frames, n_atoms). Values are in Angstrom² (Ų).

BatchSasaResult(atom_areas: NDArray[numpy.float32])
atom_areas: NDArray[numpy.float32]
n_frames: int
306    @property
307    def n_frames(self) -> int:
308        """Number of frames."""
309        return self.atom_areas.shape[0]

Number of frames.

n_atoms: int
311    @property
312    def n_atoms(self) -> int:
313        """Number of atoms."""
314        return self.atom_areas.shape[1]

Number of atoms.

total_areas: NDArray[numpy.float32]
316    @property
317    def total_areas(self) -> NDArray[np.float32]:
318        """Total SASA per frame, shape (n_frames,)."""
319        return self.atom_areas.sum(axis=1)

Total SASA per frame, shape (n_frames,).

def process_directory( input_dir: str | pathlib._local.Path, *, output_dir: str | pathlib._local.Path | None = None, algorithm: Literal['sr', 'lr'] = 'sr', n_points: int = 100, n_slices: int = 20, probe_radius: float = 1.4, n_threads: int = 0, classifier: ClassifierType | None = <ClassifierType.CCD: 3>, include_hydrogens: bool = False, include_hetatm: bool = False) -> BatchDirResult:
 71def process_directory(
 72    input_dir: str | Path,
 73    *,
 74    output_dir: str | Path | None = None,
 75    algorithm: Literal["sr", "lr"] = "sr",
 76    n_points: int = 100,
 77    n_slices: int = 20,
 78    probe_radius: float = 1.4,
 79    n_threads: int = 0,
 80    classifier: ClassifierType | None = ClassifierType.CCD,
 81    include_hydrogens: bool = False,
 82    include_hetatm: bool = False,
 83) -> BatchDirResult:
 84    """Process all supported structure files in a directory for SASA calculation.
 85
 86    Supported formats: PDB (.pdb), mmCIF (.cif, .mmcif), BinaryCIF (.bcif),
 87    PDB/ENT (.ent), JSON (.json), and their gzip- or zstd-compressed variants
 88    (.gz, .zst).
 89
 90    Args:
 91        input_dir: Path to directory containing structure files.
 92        output_dir: Optional path for per-file output. None = no file output.
 93        algorithm: Algorithm to use: "sr" (Shrake-Rupley) or "lr" (Lee-Richards).
 94        n_points: Number of test points per atom (SR only; ignored for LR).
 95            Default: 100.
 96        n_slices: Number of slices per atom (LR only; ignored for SR).
 97            Default: 20.
 98        probe_radius: Water probe radius in Angstroms. Default: 1.4.
 99        n_threads: Number of threads to use. 0 = auto-detect. Default: 0.
100        classifier: Classifier for radius assignment. None = use input radii.
101            Default: ClassifierType.CCD.
102        include_hydrogens: Whether to include hydrogen atoms. Default: False.
103        include_hetatm: Whether to include HETATM records. Default: False.
104
105    Returns:
106        BatchDirResult with per-file details.
107
108    Raises:
109        ValueError: If input parameters are invalid.
110        FileNotFoundError: If the input directory does not exist.
111        MemoryError: If out of memory.
112        RuntimeError: For other processing errors.
113
114    Example:
115        >>> from zsasa import process_directory
116        >>> result = process_directory("path/to/pdbs/")
117        >>> print(f"Processed {result.successful}/{result.total_files} files")
118    """
119    ffi, lib = _get_lib()
120
121    # Validate algorithm
122    if algorithm == "sr":
123        algo_int = ZSASA_ALGORITHM_SR
124        n_points_val = n_points
125    elif algorithm == "lr":
126        algo_int = ZSASA_ALGORITHM_LR
127        n_points_val = n_slices
128    else:
129        msg = f"Unknown algorithm: {algorithm}. Use 'sr' or 'lr'."
130        raise ValueError(msg)
131
132    if n_points <= 0:
133        msg = f"n_points must be positive, got {n_points}"
134        raise ValueError(msg)
135    if n_slices <= 0:
136        msg = f"n_slices must be positive, got {n_slices}"
137        raise ValueError(msg)
138    if probe_radius <= 0:
139        msg = f"probe_radius must be positive, got {probe_radius}"
140        raise ValueError(msg)
141    if n_threads < 0:
142        msg = f"n_threads must be non-negative, got {n_threads}"
143        raise ValueError(msg)
144
145    # Map classifier
146    classifier_int = int(classifier) if classifier is not None else -1
147
148    # Convert paths
149    input_dir_bytes = str(input_dir).encode("utf-8")
150    output_dir_bytes = str(output_dir).encode("utf-8") if output_dir is not None else ffi.NULL
151
152    error_code = ffi.new("int*")
153
154    handle = lib.zsasa_batch_dir_process(
155        input_dir_bytes,
156        output_dir_bytes,
157        algo_int,
158        n_points_val,
159        probe_radius,
160        n_threads,
161        classifier_int,
162        int(include_hydrogens),
163        int(include_hetatm),
164        error_code,
165    )
166
167    if handle == ffi.NULL:
168        ec = error_code[0]
169        if ec == ZSASA_ERROR_INVALID_INPUT:
170            msg = "Invalid input parameters for directory batch processing"
171            raise ValueError(msg)
172        elif ec == ZSASA_ERROR_OUT_OF_MEMORY:
173            msg = "Out of memory during directory batch processing"
174            raise MemoryError(msg)
175        elif ec == ZSASA_ERROR_CALCULATION:
176            msg = "SASA calculation failed during directory batch processing"
177            raise RuntimeError(msg)
178        elif ec == ZSASA_ERROR_FILE_IO:
179            msg = f"Directory not found or not readable: {input_dir}"
180            raise FileNotFoundError(msg)
181        else:
182            msg = f"Directory batch processing failed with error code: {ec}"
183            raise RuntimeError(msg)
184
185    try:
186        total_files = lib.zsasa_batch_dir_get_total_files(handle)
187        successful = lib.zsasa_batch_dir_get_successful(handle)
188        failed = lib.zsasa_batch_dir_get_failed(handle)
189
190        filenames: list[str] = []
191        n_atoms_list: list[int] = []
192        total_sasa_list: list[float] = []
193        status_list: list[int] = []
194
195        for i in range(total_files):
196            fname_ptr = lib.zsasa_batch_dir_get_filename(handle, i)
197            if fname_ptr == ffi.NULL:
198                msg = (
199                    f"Internal error: zsasa_batch_dir_get_filename returned NULL "
200                    f"for index {i} (total_files={total_files})"
201                )
202                raise RuntimeError(msg)
203            filenames.append(ffi.string(fname_ptr).decode("utf-8"))
204            n_atoms_list.append(lib.zsasa_batch_dir_get_n_atoms(handle, i))
205            sasa = lib.zsasa_batch_dir_get_total_sasa(handle, i)
206            total_sasa_list.append(float("nan") if math.isnan(sasa) else sasa)
207            st = lib.zsasa_batch_dir_get_status(handle, i)
208            if st not in (0, 1):
209                msg = f"Internal error: unexpected status {st} for file index {i} (expected 0 or 1)"
210                raise RuntimeError(msg)
211            status_list.append(st)
212
213        return BatchDirResult(
214            total_files=total_files,
215            successful=successful,
216            failed=failed,
217            filenames=filenames,
218            n_atoms=n_atoms_list,
219            total_sasa=total_sasa_list,
220            status=status_list,
221        )
222    finally:
223        lib.zsasa_batch_dir_free(handle)

Process all supported structure files in a directory for SASA calculation.

Supported formats: PDB (.pdb), mmCIF (.cif, .mmcif), BinaryCIF (.bcif), PDB/ENT (.ent), JSON (.json), and their gzip- or zstd-compressed variants (.gz, .zst).

Args: input_dir: Path to directory containing structure files. output_dir: Optional path for per-file output. None = no file output. algorithm: Algorithm to use: "sr" (Shrake-Rupley) or "lr" (Lee-Richards). n_points: Number of test points per atom (SR only; ignored for LR). Default: 100. n_slices: Number of slices per atom (LR only; ignored for SR). Default: 20. probe_radius: Water probe radius in Angstroms. Default: 1.4. n_threads: Number of threads to use. 0 = auto-detect. Default: 0. classifier: Classifier for radius assignment. None = use input radii. Default: ClassifierType.CCD. include_hydrogens: Whether to include hydrogen atoms. Default: False. include_hetatm: Whether to include HETATM records. Default: False.

Returns: BatchDirResult with per-file details.

Raises: ValueError: If input parameters are invalid. FileNotFoundError: If the input directory does not exist. MemoryError: If out of memory. RuntimeError: For other processing errors.

Example:

from zsasa import process_directory result = process_directory("path/to/pdbs/") print(f"Processed {result.successful}/{result.total_files} files")

@dataclass
class BatchDirResult:
23@dataclass
24class BatchDirResult:
25    """Result of directory batch processing.
26
27    Attributes:
28        total_files: Number of supported structure files found in the directory.
29        successful: Number of successfully processed files.
30        failed: Number of files that failed processing.
31        filenames: List of filenames (file name only, without directory path).
32        n_atoms: Per-file atom counts.
33        total_sasa: Per-file total SASA in Angstroms² (NaN for failed files).
34        status: Per-file status (1=ok, 0=failed).
35    """
36
37    total_files: int
38    successful: int
39    failed: int
40    filenames: list[str]
41    n_atoms: list[int]
42    total_sasa: list[float]
43    status: list[int]
44
45    def __post_init__(self) -> None:
46        n = len(self.filenames)
47        if len(self.n_atoms) != n or len(self.total_sasa) != n or len(self.status) != n:
48            msg = (
49                f"All per-file lists must have the same length as filenames ({n}), "
50                f"got n_atoms={len(self.n_atoms)}, total_sasa={len(self.total_sasa)}, "
51                f"status={len(self.status)}"
52            )
53            raise ValueError(msg)
54        if self.total_files != n:
55            msg = f"total_files ({self.total_files}) != len(filenames) ({n})"
56            raise ValueError(msg)
57        if self.successful + self.failed != self.total_files:
58            msg = (
59                f"successful ({self.successful}) + failed ({self.failed}) "
60                f"!= total_files ({self.total_files})"
61            )
62            raise ValueError(msg)
63
64    def __repr__(self) -> str:
65        return (
66            f"BatchDirResult(total_files={self.total_files}, "
67            f"successful={self.successful}, failed={self.failed})"
68        )

Result of directory batch processing.

Attributes: total_files: Number of supported structure files found in the directory. successful: Number of successfully processed files. failed: Number of files that failed processing. filenames: List of filenames (file name only, without directory path). n_atoms: Per-file atom counts. total_sasa: Per-file total SASA in Angstroms² (NaN for failed files). status: Per-file status (1=ok, 0=failed).

BatchDirResult( total_files: int, successful: int, failed: int, filenames: list[str], n_atoms: list[int], total_sasa: list[float], status: list[int])
total_files: int
successful: int
failed: int
filenames: list[str]
n_atoms: list[int]
total_sasa: list[float]
status: list[int]
class ClassifierType(enum.IntEnum):
26class ClassifierType(IntEnum):
27    """Available classifier types for atom radius assignment.
28
29    Attributes:
30        CCD: CCD-based radii (default). Hardcoded ProtOr radii + runtime CCD
31             analysis for non-standard residues via bond topology.
32        PROTOR: Alias for CCD. Kept for backward compatibility.
33        NACCESS: NACCESS-compatible radii.
34        OONS: Ooi, Oobatake, Nemethy, Scheraga radii.
35    """
36
37    NACCESS = ZSASA_CLASSIFIER_NACCESS
38    PROTOR = ZSASA_CLASSIFIER_PROTOR
39    OONS = ZSASA_CLASSIFIER_OONS
40    CCD = ZSASA_CLASSIFIER_CCD

Available classifier types for atom radius assignment.

Attributes: CCD: CCD-based radii (default). Hardcoded ProtOr radii + runtime CCD analysis for non-standard residues via bond topology. PROTOR: Alias for CCD. Kept for backward compatibility. NACCESS: NACCESS-compatible radii. OONS: Ooi, Oobatake, Nemethy, Scheraga radii.

NACCESS = <ClassifierType.NACCESS: 0>
PROTOR = <ClassifierType.PROTOR: 1>
OONS = <ClassifierType.OONS: 2>
CCD = <ClassifierType.CCD: 3>
class AtomClass(enum.IntEnum):
43class AtomClass(IntEnum):
44    """Atom polarity classes.
45
46    Attributes:
47        POLAR: Polar atoms (e.g., N, O).
48        APOLAR: Apolar/hydrophobic atoms (e.g., C).
49        UNKNOWN: Unknown classification.
50    """
51
52    POLAR = ZSASA_ATOM_CLASS_POLAR
53    APOLAR = ZSASA_ATOM_CLASS_APOLAR
54    UNKNOWN = ZSASA_ATOM_CLASS_UNKNOWN

Atom polarity classes.

Attributes: POLAR: Polar atoms (e.g., N, O). APOLAR: Apolar/hydrophobic atoms (e.g., C). UNKNOWN: Unknown classification.

POLAR = <AtomClass.POLAR: 0>
APOLAR = <AtomClass.APOLAR: 1>
UNKNOWN = <AtomClass.UNKNOWN: 2>
@dataclass
class ClassificationResult:
173@dataclass
174class ClassificationResult:
175    """Result of batch atom classification.
176
177    Attributes:
178        radii: Per-atom van der Waals radii in Angstroms.
179               NaN values indicate atoms not found in the classifier.
180               Use np.isnan(result.radii) to find unknown atoms.
181        classes: Per-atom polarity classes (AtomClass constants).
182
183    Example:
184        >>> result = classify_atoms(residues, atoms)
185        >>> unknown_mask = np.isnan(result.radii)
186        >>> if unknown_mask.any():
187        ...     print(f"Found {unknown_mask.sum()} unknown atoms")
188    """
189
190    radii: NDArray[np.float64]
191    classes: NDArray[np.int32]
192
193    def __repr__(self) -> str:
194        return f"ClassificationResult(n_atoms={len(self.radii)})"

Result of batch atom classification.

Attributes: radii: Per-atom van der Waals radii in Angstroms. NaN values indicate atoms not found in the classifier. Use np.isnan(result.radii) to find unknown atoms. classes: Per-atom polarity classes (AtomClass constants).

Example:

result = classify_atoms(residues, atoms) unknown_mask = np.isnan(result.radii) if unknown_mask.any(): ... print(f"Found {unknown_mask.sum()} unknown atoms")

ClassificationResult(radii: NDArray[numpy.float64], classes: NDArray[numpy.int32])
radii: NDArray[numpy.float64]
classes: NDArray[numpy.int32]
def get_radius( residue: str, atom: str, classifier_type: ClassifierType = <ClassifierType.CCD: 3>) -> float | None:
57def get_radius(
58    residue: str,
59    atom: str,
60    classifier_type: ClassifierType = ClassifierType.CCD,
61) -> float | None:
62    """Get van der Waals radius for an atom using the specified classifier.
63
64    Args:
65        residue: Residue name (e.g., "ALA", "GLY").
66        atom: Atom name (e.g., "CA", "CB").
67        classifier_type: Classifier to use. Default: ClassifierType.CCD.
68
69    Returns:
70        Radius in Angstroms, or None if atom is not found in classifier.
71
72    Example:
73        >>> from zsasa import get_radius, ClassifierType
74        >>> get_radius("ALA", "CA")
75        1.87
76        >>> get_radius("ALA", "XX")  # Unknown atom
77        None
78    """
79    _, lib = _get_lib()
80    radius = lib.zsasa_classifier_get_radius(
81        classifier_type,
82        residue.encode("utf-8"),
83        atom.encode("utf-8"),
84    )
85    if np.isnan(radius):
86        return None
87    return radius

Get van der Waals radius for an atom using the specified classifier.

Args: residue: Residue name (e.g., "ALA", "GLY"). atom: Atom name (e.g., "CA", "CB"). classifier_type: Classifier to use. Default: ClassifierType.CCD.

Returns: Radius in Angstroms, or None if atom is not found in classifier.

Example:

from zsasa import get_radius, ClassifierType get_radius("ALA", "CA") 1.87 get_radius("ALA", "XX") # Unknown atom None

def get_atom_class( residue: str, atom: str, classifier_type: ClassifierType = <ClassifierType.CCD: 3>) -> int:
 90def get_atom_class(
 91    residue: str,
 92    atom: str,
 93    classifier_type: ClassifierType = ClassifierType.CCD,
 94) -> int:
 95    """Get atom polarity class using the specified classifier.
 96
 97    Args:
 98        residue: Residue name (e.g., "ALA", "GLY").
 99        atom: Atom name (e.g., "CA", "CB").
100        classifier_type: Classifier to use. Default: ClassifierType.CCD.
101
102    Returns:
103        AtomClass constant (POLAR, APOLAR, or UNKNOWN).
104
105    Example:
106        >>> from zsasa import get_atom_class, AtomClass
107        >>> get_atom_class("ALA", "CA") == AtomClass.APOLAR
108        True
109        >>> get_atom_class("ALA", "O") == AtomClass.POLAR
110        True
111    """
112    _, lib = _get_lib()
113    return lib.zsasa_classifier_get_class(
114        classifier_type,
115        residue.encode("utf-8"),
116        atom.encode("utf-8"),
117    )

Get atom polarity class using the specified classifier.

Args: residue: Residue name (e.g., "ALA", "GLY"). atom: Atom name (e.g., "CA", "CB"). classifier_type: Classifier to use. Default: ClassifierType.CCD.

Returns: AtomClass constant (POLAR, APOLAR, or UNKNOWN).

Example:

from zsasa import get_atom_class, AtomClass get_atom_class("ALA", "CA") == AtomClass.APOLAR True get_atom_class("ALA", "O") == AtomClass.POLAR True

def guess_radius(element: str) -> float | None:
120def guess_radius(element: str) -> float | None:
121    """Guess van der Waals radius from element symbol.
122
123    Args:
124        element: Element symbol (e.g., "C", "N", "FE").
125                 Case-insensitive, whitespace is trimmed.
126
127    Returns:
128        Radius in Angstroms, or None if element is not recognized.
129
130    Example:
131        >>> from zsasa import guess_radius
132        >>> guess_radius("C")
133        1.7
134        >>> guess_radius("FE")
135        1.26
136        >>> guess_radius("XX")  # Unknown
137        None
138    """
139    _, lib = _get_lib()
140    radius = lib.zsasa_guess_radius(element.encode("utf-8"))
141    if np.isnan(radius):
142        return None
143    return radius

Guess van der Waals radius from element symbol.

Args: element: Element symbol (e.g., "C", "N", "FE"). Case-insensitive, whitespace is trimmed.

Returns: Radius in Angstroms, or None if element is not recognized.

Example:

from zsasa import guess_radius guess_radius("C") 1.7 guess_radius("FE") 1.26 guess_radius("XX") # Unknown None

def guess_radius_from_atom_name(atom_name: str) -> float | None:
146def guess_radius_from_atom_name(atom_name: str) -> float | None:
147    """Guess van der Waals radius from PDB-style atom name.
148
149    Extracts element symbol from atom name following PDB conventions:
150    - Leading space indicates single-char element (e.g., " CA " = Carbon alpha)
151    - No leading space may indicate 2-char element (e.g., "FE  " = Iron)
152
153    Args:
154        atom_name: PDB-style atom name (e.g., " CA ", "FE  ").
155
156    Returns:
157        Radius in Angstroms, or None if element cannot be determined.
158
159    Example:
160        >>> from zsasa import guess_radius_from_atom_name
161        >>> guess_radius_from_atom_name(" CA ")  # Carbon alpha
162        1.7
163        >>> guess_radius_from_atom_name("FE  ")  # Iron
164        1.26
165    """
166    _, lib = _get_lib()
167    radius = lib.zsasa_guess_radius_from_atom_name(atom_name.encode("utf-8"))
168    if np.isnan(radius):
169        return None
170    return radius

Guess van der Waals radius from PDB-style atom name.

Extracts element symbol from atom name following PDB conventions:

  • Leading space indicates single-char element (e.g., " CA " = Carbon alpha)
  • No leading space may indicate 2-char element (e.g., "FE " = Iron)

Args: atom_name: PDB-style atom name (e.g., " CA ", "FE ").

Returns: Radius in Angstroms, or None if element cannot be determined.

Example:

from zsasa import guess_radius_from_atom_name guess_radius_from_atom_name(" CA ") # Carbon alpha 1.7 guess_radius_from_atom_name("FE ") # Iron 1.26

def classify_atoms( residues: list[str], atoms: list[str], classifier_type: ClassifierType = <ClassifierType.CCD: 3>, *, include_classes: bool = True) -> ClassificationResult:
197def classify_atoms(
198    residues: list[str],
199    atoms: list[str],
200    classifier_type: ClassifierType = ClassifierType.CCD,
201    *,
202    include_classes: bool = True,
203) -> ClassificationResult:
204    """Classify multiple atoms at once (batch operation).
205
206    This is more efficient than calling get_radius for each atom individually.
207
208    Args:
209        residues: List of residue names.
210        atoms: List of atom names (must be same length as residues).
211        classifier_type: Classifier to use. Default: ClassifierType.CCD.
212        include_classes: Whether to compute atom classes. Default: True.
213
214    Returns:
215        ClassificationResult with radii and classes arrays.
216        Unknown atoms have NaN radius and UNKNOWN class.
217
218    Raises:
219        ValueError: If residues and atoms have different lengths.
220
221    Example:
222        >>> from zsasa import classify_atoms
223        >>> result = classify_atoms(
224        ...     ["ALA", "ALA", "GLY"],
225        ...     ["CA", "O", "N"],
226        ... )
227        >>> result.radii
228        array([1.87, 1.4 , 1.65])
229    """
230    ffi, lib = _get_lib()
231
232    if len(residues) != len(atoms):
233        msg = f"residues and atoms must have same length: {len(residues)} != {len(atoms)}"
234        raise ValueError(msg)
235
236    n_atoms = len(residues)
237    if n_atoms == 0:
238        return ClassificationResult(
239            radii=np.array([], dtype=np.float64),
240            classes=np.array([], dtype=np.int32),
241        )
242
243    # Encode strings and create cffi arrays
244    # Keep references to prevent garbage collection
245    residues_bytes = [ffi.new("char[]", r.encode("utf-8")) for r in residues]
246    atoms_bytes = [ffi.new("char[]", a.encode("utf-8")) for a in atoms]
247
248    residues_arr = ffi.new("char*[]", residues_bytes)
249    atoms_arr = ffi.new("char*[]", atoms_bytes)
250
251    # Allocate output arrays
252    radii = np.zeros(n_atoms, dtype=np.float64)
253    classes = np.zeros(n_atoms, dtype=np.int32) if include_classes else None
254
255    radii_ptr = ffi.cast("double*", radii.ctypes.data)
256    classes_ptr = ffi.cast("int*", classes.ctypes.data) if include_classes else ffi.NULL
257
258    result = lib.zsasa_classify_atoms(
259        classifier_type,
260        residues_arr,
261        atoms_arr,
262        n_atoms,
263        radii_ptr,
264        classes_ptr,
265    )
266
267    if result == ZSASA_ERROR_INVALID_INPUT:
268        msg = f"Invalid classifier type: {classifier_type}"
269        raise ValueError(msg)
270    elif result != ZSASA_OK:
271        msg = f"Classification error: {result}"
272        raise RuntimeError(msg)
273
274    if not include_classes:
275        classes = np.full(n_atoms, AtomClass.UNKNOWN, dtype=np.int32)
276
277    return ClassificationResult(radii=radii, classes=classes)

Classify multiple atoms at once (batch operation).

This is more efficient than calling get_radius for each atom individually.

Args: residues: List of residue names. atoms: List of atom names (must be same length as residues). classifier_type: Classifier to use. Default: ClassifierType.CCD. include_classes: Whether to compute atom classes. Default: True.

Returns: ClassificationResult with radii and classes arrays. Unknown atoms have NaN radius and UNKNOWN class.

Raises: ValueError: If residues and atoms have different lengths.

Example:

from zsasa import classify_atoms result = classify_atoms( ... ["ALA", "ALA", "GLY"], ... ["CA", "O", "N"], ... ) result.radii array([1.87, 1.4 , 1.65])

MAX_SASA = {'ALA': 129.0, 'ARG': 274.0, 'ASN': 195.0, 'ASP': 193.0, 'CYS': 167.0, 'GLN': 225.0, 'GLU': 223.0, 'GLY': 104.0, 'HIS': 224.0, 'ILE': 197.0, 'LEU': 201.0, 'LYS': 236.0, 'MET': 224.0, 'PHE': 240.0, 'PRO': 159.0, 'SER': 155.0, 'THR': 172.0, 'TRP': 285.0, 'TYR': 263.0, 'VAL': 174.0}
def get_max_sasa(residue_name: str) -> float | None:
36def get_max_sasa(residue_name: str) -> float | None:
37    """Get maximum SASA value for a standard amino acid.
38
39    Values from Tien et al. (2013) "Maximum allowed solvent accessibilities
40    of residues in proteins".
41
42    Args:
43        residue_name: 3-letter residue code (e.g., "ALA", "GLY").
44
45    Returns:
46        Maximum SASA in Angstroms², or None if residue is not a standard amino acid.
47
48    Example:
49        >>> from zsasa import get_max_sasa
50        >>> get_max_sasa("ALA")
51        129.0
52        >>> get_max_sasa("TRP")
53        285.0
54        >>> get_max_sasa("HOH")  # Water - not a standard amino acid
55        None
56    """
57    _, lib = _get_lib()
58    max_sasa = lib.zsasa_get_max_sasa(residue_name.encode("utf-8"))
59    if np.isnan(max_sasa):
60        return None
61    return max_sasa

Get maximum SASA value for a standard amino acid.

Values from Tien et al. (2013) "Maximum allowed solvent accessibilities of residues in proteins".

Args: residue_name: 3-letter residue code (e.g., "ALA", "GLY").

Returns: Maximum SASA in Angstroms², or None if residue is not a standard amino acid.

Example:

from zsasa import get_max_sasa get_max_sasa("ALA") 129.0 get_max_sasa("TRP") 285.0 get_max_sasa("HOH") # Water - not a standard amino acid None

def calculate_rsa(sasa: float, residue_name: str) -> float | None:
64def calculate_rsa(sasa: float, residue_name: str) -> float | None:
65    """Calculate RSA (Relative Solvent Accessibility) for a single residue.
66
67    RSA = SASA / MaxSASA
68
69    Args:
70        sasa: Observed SASA value in Angstroms².
71        residue_name: 3-letter residue code (e.g., "ALA", "GLY").
72
73    Returns:
74        RSA value (typically 0.0-1.0), or None if residue is not a standard amino acid.
75        Note: RSA > 1.0 is possible for exposed terminal residues.
76
77    Example:
78        >>> from zsasa import calculate_rsa
79        >>> calculate_rsa(64.5, "ALA")  # 64.5 / 129.0 = 0.5
80        0.5
81        >>> calculate_rsa(150.0, "GLY")  # RSA > 1.0 is possible
82        1.4423076923076923
83    """
84    _, lib = _get_lib()
85    rsa = lib.zsasa_calculate_rsa(sasa, residue_name.encode("utf-8"))
86    if np.isnan(rsa):
87        return None
88    return rsa

Calculate RSA (Relative Solvent Accessibility) for a single residue.

RSA = SASA / MaxSASA

Args: sasa: Observed SASA value in Angstroms². residue_name: 3-letter residue code (e.g., "ALA", "GLY").

Returns: RSA value (typically 0.0-1.0), or None if residue is not a standard amino acid. Note: RSA > 1.0 is possible for exposed terminal residues.

Example:

from zsasa import calculate_rsa calculate_rsa(64.5, "ALA") # 64.5 / 129.0 = 0.5 0.5 calculate_rsa(150.0, "GLY") # RSA > 1.0 is possible 1.4423076923076923

def calculate_rsa_batch( sasas: NDArray[numpy.float64] | list[float], residue_names: list[str]) -> NDArray[numpy.float64]:
 91def calculate_rsa_batch(
 92    sasas: NDArray[np.float64] | list[float],
 93    residue_names: list[str],
 94) -> NDArray[np.float64]:
 95    """Calculate RSA for multiple residues at once (batch operation).
 96
 97    This is more efficient than calling calculate_rsa for each residue individually.
 98
 99    Args:
100        sasas: Array of SASA values in Angstroms².
101        residue_names: List of 3-letter residue codes (must be same length as sasas).
102
103    Returns:
104        Array of RSA values. NaN values indicate non-standard amino acids.
105
106    Raises:
107        ValueError: If sasas and residue_names have different lengths.
108
109    Example:
110        >>> import numpy as np
111        >>> from zsasa import calculate_rsa_batch
112        >>> sasas = np.array([64.5, 52.0, 100.0])
113        >>> residues = ["ALA", "GLY", "HOH"]  # HOH is not standard
114        >>> rsa = calculate_rsa_batch(sasas, residues)
115        >>> rsa
116        array([0.5       , 0.5       ,        nan])
117    """
118    ffi, lib = _get_lib()
119
120    sasas = np.ascontiguousarray(sasas, dtype=np.float64)
121    n_residues = len(sasas)
122
123    if len(residue_names) != n_residues:
124        msg = f"sasas and residue_names must have same length: {n_residues} != {len(residue_names)}"
125        raise ValueError(msg)
126
127    if n_residues == 0:
128        return np.array([], dtype=np.float64)
129
130    # Encode strings and create cffi array
131    # Keep references to prevent garbage collection
132    residues_bytes = [ffi.new("char[]", r.encode("utf-8")) for r in residue_names]
133    residues_arr = ffi.new("char*[]", residues_bytes)
134
135    # Allocate output array
136    rsa_out = np.zeros(n_residues, dtype=np.float64)
137
138    sasas_ptr = ffi.cast("double*", sasas.ctypes.data)
139    rsa_ptr = ffi.cast("double*", rsa_out.ctypes.data)
140
141    result = lib.zsasa_calculate_rsa_batch(sasas_ptr, residues_arr, n_residues, rsa_ptr)
142    if result != ZSASA_OK:
143        msg = f"RSA batch calculation failed with error code: {result}"
144        raise RuntimeError(msg)
145
146    return rsa_out

Calculate RSA for multiple residues at once (batch operation).

This is more efficient than calling calculate_rsa for each residue individually.

Args: sasas: Array of SASA values in Angstroms². residue_names: List of 3-letter residue codes (must be same length as sasas).

Returns: Array of RSA values. NaN values indicate non-standard amino acids.

Raises: ValueError: If sasas and residue_names have different lengths.

Example:

import numpy as np from zsasa import calculate_rsa_batch sasas = np.array([64.5, 52.0, 100.0]) residues = ["ALA", "GLY", "HOH"] # HOH is not standard rsa = calculate_rsa_batch(sasas, residues) rsa array([0.5 , 0.5 , nan])

@dataclass
class ResidueResult:
41@dataclass
42class ResidueResult:
43    """Per-residue SASA result.
44
45    Attributes:
46        chain_id: Chain identifier (e.g., "A", "B").
47        residue_id: Residue sequence number.
48        residue_name: 3-letter residue name (e.g., "ALA", "GLY").
49        total_area: Total SASA for this residue in A^2.
50        polar_area: Polar SASA (N, O atoms, etc.) in A^2.
51        apolar_area: Apolar SASA (C atoms, etc.) in A^2.
52        rsa: Relative Solvent Accessibility (0.0-1.0+), or None for
53             non-standard amino acids.
54        n_atoms: Number of atoms in this residue.
55    """
56
57    chain_id: str
58    residue_id: int
59    residue_name: str
60    total_area: float
61    polar_area: float
62    apolar_area: float
63    rsa: float | None
64    n_atoms: int
65
66    def __repr__(self) -> str:
67        rsa_str = f"{self.rsa:.3f}" if self.rsa is not None else "None"
68        return (
69            f"ResidueResult({self.chain_id}:{self.residue_name}{self.residue_id}, "
70            f"total={self.total_area:.1f}, rsa={rsa_str}, n_atoms={self.n_atoms})"
71        )

Per-residue SASA result.

Attributes: chain_id: Chain identifier (e.g., "A", "B"). residue_id: Residue sequence number. residue_name: 3-letter residue name (e.g., "ALA", "GLY"). total_area: Total SASA for this residue in A^2. polar_area: Polar SASA (N, O atoms, etc.) in A^2. apolar_area: Apolar SASA (C atoms, etc.) in A^2. rsa: Relative Solvent Accessibility (0.0-1.0+), or None for non-standard amino acids. n_atoms: Number of atoms in this residue.

ResidueResult( chain_id: str, residue_id: int, residue_name: str, total_area: float, polar_area: float, apolar_area: float, rsa: float | None, n_atoms: int)
chain_id: str
residue_id: int
residue_name: str
total_area: float
polar_area: float
apolar_area: float
rsa: float | None
n_atoms: int
def aggregate_by_residue( atom_areas: NDArray[numpy.float64], chain_ids: list[str], residue_ids: list[int], residue_names: list[str], atom_classes: NDArray[numpy.int32] | None = None) -> list[ResidueResult]:
 74def aggregate_by_residue(
 75    atom_areas: NDArray[np.float64],
 76    chain_ids: list[str],
 77    residue_ids: list[int],
 78    residue_names: list[str],
 79    atom_classes: NDArray[np.int32] | None = None,
 80) -> list[ResidueResult]:
 81    """Aggregate per-atom SASA values to per-residue.
 82
 83    Groups atoms by (chain_id, residue_id) and sums their SASA values.
 84    Also calculates polar/apolar breakdown and RSA if atom classes are provided.
 85
 86    Args:
 87        atom_areas: Per-atom SASA values in A^2.
 88        chain_ids: Chain ID for each atom.
 89        residue_ids: Residue sequence number for each atom.
 90        residue_names: Residue name for each atom.
 91        atom_classes: Optional per-atom polarity classes (AtomClass values).
 92                      If provided, polar_area and apolar_area will be calculated.
 93
 94    Returns:
 95        List of ResidueResult objects, one per unique residue.
 96        Results are ordered by appearance in the input (preserves chain/residue order).
 97
 98    Example:
 99        >>> import numpy as np
100        >>> from zsasa.analysis import aggregate_by_residue
101        >>>
102        >>> atom_areas = np.array([10.0, 20.0, 15.0, 25.0])
103        >>> chain_ids = ["A", "A", "A", "A"]
104        >>> residue_ids = [1, 1, 2, 2]
105        >>> residue_names = ["ALA", "ALA", "GLY", "GLY"]
106        >>>
107        >>> residues = aggregate_by_residue(
108        ...     atom_areas, chain_ids, residue_ids, residue_names
109        ... )
110        >>> len(residues)
111        2
112        >>> residues[0].total_area
113        30.0
114    """
115    n_atoms = len(atom_areas)
116    if n_atoms == 0:
117        return []
118
119    # Validate input lengths
120    if len(chain_ids) != n_atoms:
121        msg = f"chain_ids length ({len(chain_ids)}) != atom_areas length ({n_atoms})"
122        raise ValueError(msg)
123    if len(residue_ids) != n_atoms:
124        msg = f"residue_ids length ({len(residue_ids)}) != atom_areas length ({n_atoms})"
125        raise ValueError(msg)
126    if len(residue_names) != n_atoms:
127        msg = f"residue_names length ({len(residue_names)}) != atom_areas length ({n_atoms})"
128        raise ValueError(msg)
129    if atom_classes is not None and len(atom_classes) != n_atoms:
130        msg = f"atom_classes length ({len(atom_classes)}) != atom_areas length ({n_atoms})"
131        raise ValueError(msg)
132
133    # Group atoms by (chain_id, residue_id)
134    # Use dict to preserve insertion order (Python 3.7+)
135    residue_data: dict[tuple[str, int], dict] = {}
136
137    for i in range(n_atoms):
138        key = (chain_ids[i], residue_ids[i])
139
140        if key not in residue_data:
141            residue_data[key] = {
142                "residue_name": residue_names[i],
143                "total_area": 0.0,
144                "polar_area": 0.0,
145                "apolar_area": 0.0,
146                "n_atoms": 0,
147            }
148
149        data = residue_data[key]
150        area = float(atom_areas[i])
151        data["total_area"] += area
152        data["n_atoms"] += 1
153
154        if atom_classes is not None:
155            atom_class = atom_classes[i]
156            if atom_class == AtomClass.POLAR:
157                data["polar_area"] += area
158            elif atom_class == AtomClass.APOLAR:
159                data["apolar_area"] += area
160
161    # Build result list
162    results = []
163    for (chain_id, residue_id), data in residue_data.items():
164        residue_name = data["residue_name"]
165        total_area = data["total_area"]
166
167        # Calculate RSA if this is a standard amino acid
168        max_sasa = MAX_SASA.get(residue_name)
169        rsa = total_area / max_sasa if max_sasa is not None else None
170
171        results.append(
172            ResidueResult(
173                chain_id=chain_id,
174                residue_id=residue_id,
175                residue_name=residue_name,
176                total_area=total_area,
177                polar_area=data["polar_area"],
178                apolar_area=data["apolar_area"],
179                rsa=rsa,
180                n_atoms=data["n_atoms"],
181            )
182        )
183
184    return results

Aggregate per-atom SASA values to per-residue.

Groups atoms by (chain_id, residue_id) and sums their SASA values. Also calculates polar/apolar breakdown and RSA if atom classes are provided.

Args: atom_areas: Per-atom SASA values in A^2. chain_ids: Chain ID for each atom. residue_ids: Residue sequence number for each atom. residue_names: Residue name for each atom. atom_classes: Optional per-atom polarity classes (AtomClass values). If provided, polar_area and apolar_area will be calculated.

Returns: List of ResidueResult objects, one per unique residue. Results are ordered by appearance in the input (preserves chain/residue order).

Example:

import numpy as np from zsasa.analysis import aggregate_by_residue

atom_areas = np.array([10.0, 20.0, 15.0, 25.0]) chain_ids = ["A", "A", "A", "A"] residue_ids = [1, 1, 2, 2] residue_names = ["ALA", "ALA", "GLY", "GLY"]

residues = aggregate_by_residue( ... atom_areas, chain_ids, residue_ids, residue_names ... ) len(residues) 2 residues[0].total_area 30.0

def aggregate_from_result( result: zsasa.integrations._types.SasaResultWithAtoms) -> list[ResidueResult]:
187def aggregate_from_result(result: SasaResultWithAtoms) -> list[ResidueResult]:
188    """Aggregate per-atom SASA from a SasaResultWithAtoms to per-residue.
189
190    This is a convenience wrapper that extracts the necessary data from
191    a SasaResultWithAtoms object returned by gemmi integration functions.
192
193    Args:
194        result: A SasaResultWithAtoms object from calculate_sasa_from_structure
195                or calculate_sasa_from_model.
196
197    Returns:
198        List of ResidueResult objects, one per unique residue.
199
200    Example:
201        >>> from zsasa.integrations.gemmi import calculate_sasa_from_structure
202        >>> from zsasa.analysis import aggregate_from_result
203        >>>
204        >>> result = calculate_sasa_from_structure("protein.cif")
205        >>> residues = aggregate_from_result(result)
206        >>>
207        >>> # Print buried residues (RSA < 0.25)
208        >>> for res in residues:
209        ...     if res.rsa is not None and res.rsa < 0.25:
210        ...         print(f"{res.chain_id}:{res.residue_name}{res.residue_id}: {res.rsa:.1%}")
211    """
212    return aggregate_by_residue(
213        atom_areas=result.atom_areas,
214        chain_ids=result.atom_data.chain_ids,
215        residue_ids=result.atom_data.residue_ids,
216        residue_names=result.atom_data.residue_names,
217        atom_classes=result.atom_classes,
218    )

Aggregate per-atom SASA from a SasaResultWithAtoms to per-residue.

This is a convenience wrapper that extracts the necessary data from a SasaResultWithAtoms object returned by gemmi integration functions.

Args: result: A SasaResultWithAtoms object from calculate_sasa_from_structure or calculate_sasa_from_model.

Returns: List of ResidueResult objects, one per unique residue.

Example:

from zsasa.integrations.gemmi import calculate_sasa_from_structure from zsasa.analysis import aggregate_from_result

result = calculate_sasa_from_structure("protein.cif") residues = aggregate_from_result(result)

Print buried residues (RSA < 0.25)

for res in residues: ... if res.rsa is not None and res.rsa < 0.25: ... print(f"{res.chain_id}:{res.residue_name}{res.residue_id}: {res.rsa:.1%}")

def get_version() -> str:
282def get_version() -> str:
283    """Get the library version string."""
284    ffi, lib = _get_lib()
285    return ffi.string(lib.zsasa_version()).decode("utf-8")

Get the library version string.