"""Segment slicing and annotation helpers for ``AtomArray`` and ``AtomArrayStack``.
For the atom-selection string languages (the ``sel('...')`` / path-selection DSL and the
pandas-like query language), see :py:mod:`atomworks.io.utils.query`.
Key public objects:
- :py:class:`~atomworks.io.utils.selection.SegmentSlice`
- :py:func:`~atomworks.io.utils.selection.get_residue_starts`
See individual docstrings for usage and examples.
"""
__all__ = ["annot_start_stop_idxs", "get_annotation", "get_residue_starts"]
from abc import ABC, abstractmethod
from typing import Any, Literal
import biotite.structure as struc
import numpy as np
from biotite.structure import AtomArray, AtomArrayStack
import atomworks.io.utils.atom_array_plus as aap
from atomworks.io.utils.scatter import get_segments
[docs]
def annot_start_stop_idxs(
atom_array: AtomArray | AtomArrayStack, annots: str | list[str], add_exclusive_stop: bool = False
) -> np.ndarray:
"""Computes the start and stop indices for segments in an AtomArray where any of the specified annotation(s) change.
Args:
atom_array: The AtomArray to process.
annots: Annotation name or names to define segments.
add_exclusive_stop: Append an exclusive stop index at the end. Defaults to ``False``.
Returns:
1D array of start/stop indices that bound segments.
Example:
>>> atom_array = AtomArray(...)
>>> start_stop_idxs = annot_start_stop_idxs(atom_array, annots="chain_id", add_exclusive_stop=True)
>>> print(start_stop_idxs)
[0, 5, 10, 15]
"""
if atom_array.array_length() == 0:
return np.array([], dtype=int)
if isinstance(annots, str):
annots = [annots]
annots: list[str]
annot_data: list[np.ndarray] = [atom_array.get_annotation(annot) for annot in annots]
start_stop_idxs = get_segments(*annot_data, add_exclusive_stop=add_exclusive_stop)
return start_stop_idxs
[docs]
def get_residue_starts(atom_array: AtomArray | AtomArrayStack, add_exclusive_stop: bool = False) -> np.ndarray:
"""Get the start (and optionally stop) indices of residues in an AtomArray.
This is a more robust version of :py:func:`biotite.structure.residues.get_residue_starts`
that additionally differentiates residues across different ``transformation_id`` values
when present. It is backwards compatible if the annotation is absent.
Args:
atom_array: Structure to analyze.
add_exclusive_stop: Append an exclusive stop index at the end. Defaults to ``False``.
Returns:
1D array of residue boundary indices.
References:
* `Biotite get_residue_starts`_
.. _Biotite get_residue_starts: https://github.com/biotite-dev/biotite/blob/231eefed334e1d3509c1b7cb3f2bfd71d4b0eeb0/src/biotite/structure/residues.py#L35
"""
_annots_to_check = ["chain_id", "res_id", "ins_code", "transformation_id"]
existing_annots = atom_array.get_annotation_categories()
annots_to_check = [annot for annot in _annots_to_check if annot in existing_annots]
return annot_start_stop_idxs(atom_array, annots=annots_to_check, add_exclusive_stop=add_exclusive_stop)
def _validate_n_body_and_type(atom_array: AtomArray | AtomArrayStack, n_body: int, operation: str) -> None:
"""Validate ``n_body`` value and structure type.
Args:
atom_array: Structure to validate.
n_body: Annotation dimensionality (1 or 2).
operation: Description used in error messages.
Raises:
ValueError: If ``n_body > 1`` but ``atom_array`` is not ``AtomArrayPlus`` or ``AtomArrayStack``.
NotImplementedError: If ``n_body`` is not 1 or 2.
"""
if n_body > 1 and not isinstance(atom_array, (aap.AtomArrayPlus | AtomArrayStack)):
raise ValueError(f"Cannot {operation} with n_body={n_body} on non-AtomArrayPlus!")
if n_body not in (1, 2):
raise NotImplementedError(f"Cannot {operation} with n_body={n_body}!")
[docs]
def get_annotation(
atom_array: AtomArray | AtomArrayStack, annot: str, n_body: int | None = None, default: Any = None
) -> np.ndarray:
"""Return an annotation array if present, otherwise ``default``.
If ``n_body`` is ``None``, the dimensionality is auto-detected by probing 1D then 2D annotation categories.
Args:
atom_array: Structure to query.
annot: Annotation category name.
n_body: 1 for 1D annotations, 2 for 2D annotations; auto-detected if ``None``.
default: Value to return if the annotation is missing. Defaults to ``None``.
Returns:
The requested annotation array or ``default`` if missing.
"""
if n_body is not None:
_validate_n_body_and_type(atom_array, n_body, f"get annotation for {annot}")
else:
# Auto-detect annotation dimensionality if n_body not specified
for body in (1, 2):
if annot in get_annotation_categories(atom_array, n_body=body):
return get_annotation(atom_array, annot, n_body=body)
if n_body == 1 and annot in atom_array.get_annotation_categories():
return atom_array.get_annotation(annot)
elif n_body == 2 and annot in atom_array.get_annotation_2d_categories():
return atom_array.get_annotation_2d(annot)
return default
def get_annotation_categories(atom_array: AtomArray | AtomArrayStack, n_body: int | Literal["all"] = 1) -> list[str]:
"""Get annotation categories for the specified n_body.
Args:
atom_array: Structure to query.
n_body: ``1`` for 1D, ``2`` for 2D, or ``"all"`` for both.
Returns:
Names of available annotation categories for the requested dimensionality.
"""
# Map n_body to the corresponding method name
n_body_to_method = {
1: "get_annotation_categories",
2: "get_annotation_2d_categories",
}
if n_body == "all":
categories = []
for method_name in n_body_to_method.values():
if hasattr(atom_array, method_name):
categories.extend(getattr(atom_array, method_name)())
return categories
elif n_body in n_body_to_method:
method_name = n_body_to_method[n_body]
if hasattr(atom_array, method_name):
return getattr(atom_array, method_name)()
else:
return []
else:
return []
class SegmentSlice(ABC):
"""Abstract base class for slicing segments of an AtomArray or AtomArrayStack.
Provides functionality analogous to Python's built-in slice object but operates on structural segments
(e.g., residues or chains indices) rather than individual atom indices. To subclass, implement the
`_get_segment_bounds` method to return the start and stop indices of the segments.
For example:
- to slice residues 0-2: `atom_array[ResIdxSlice(0, 2)]`
- to slice chains 0-1: `atom_array[ChainIdxSlice(0, 2)]`
- to slice to the last two residues: `atom_array[ResIdxSlice(-2, None)]`
Args:
start: Starting segment index. Defaults to ``None``.
stop: Exclusive ending segment index. Defaults to ``None``.
"""
def __init__(self, start: int | None = None, stop: int | None = None):
self.start = start
self.stop = stop
@abstractmethod
def _get_segment_bounds(self, atom_array: AtomArray | AtomArrayStack) -> np.ndarray:
pass
def __call__(self, atom_array: AtomArray | AtomArrayStack) -> slice:
"""Creates a slice object for the specified segment range in the atom array.
Args:
atom_array: Structure to slice.
Returns:
A Python ``slice`` that can be used to index ``atom_array``.
"""
seg_bounds = self._get_segment_bounds(atom_array)
n_segments = len(seg_bounds) - 1
if n_segments < 0:
# edge case: empty array
return slice(0, 0)
seg_slice = slice(self.start, self.stop)
start, stop, _ = seg_slice.indices(n_segments)
return slice(seg_bounds[start], seg_bounds[stop])
class ResIdxSlice(SegmentSlice):
"""Slice atoms by residue indices.
Residues are segmented by changes in ``chain_id``, ``res_name``, ``res_id``,
``ins_code``, or ``transformation_id``.
Example:
>>> atom_array = AtomArray(...)
>>> res_slice = ResIdxSlice(0, 2)
>>> sliced_atom_array = atom_array[res_slice] # <-- returns a new AtomArray with the first two residues
"""
def _get_segment_bounds(self, atom_array: AtomArray | AtomArrayStack) -> np.ndarray:
return get_residue_starts(atom_array, add_exclusive_stop=True)
class ChainIdxSlice(SegmentSlice):
"""Slice atoms by chain indices.
Allows for selecting ranges of chains using Python slice-like syntax. Each chain is considered
as a segment, defined by changes in the chain_id annotation.
Example:
>>> atom_array = AtomArray(...)
>>> chain_slice = ChainIdxSlice(0, 1)
>>> sliced_atom_array = atom_array[chain_slice] # <-- returns a new AtomArray with the first chain
"""
def _get_segment_bounds(self, atom_array: AtomArray | AtomArrayStack) -> np.ndarray:
return struc.get_chain_starts(atom_array, add_exclusive_stop=True)