Source code for atomworks.ml.utils.testing

import os
from pathlib import Path
from tempfile import TemporaryDirectory

import numpy as np
import torch
from biotite.structure import AtomArray
from scipy.spatial import cKDTree

from atomworks.common import immutable_lru_cache
from atomworks.constants import PDB_MIRROR_PATH
from atomworks.io import parse
from atomworks.io.config import ParseConfig
from atomworks.io.utils.atom_array_plus import (
    AnnotationList2D,
    AtomArrayPlus,
)
from atomworks.io.utils.io_utils import (
    to_cif_file,
)
from atomworks.io.utils.standard_annotations import STANDARD_ANNOTATIONS
from atomworks.ml.conditions import C_CTR, C_NTR, CONDITIONS


[docs] def get_pdb_mirror_path(pdbid: str, base_dir: str = PDB_MIRROR_PATH) -> str: """Convenience util to get the path to a CIF file on the DIGS""" # Assert that the base directory exists assert os.path.exists(base_dir), f"Base directory {base_dir} does not exist" # Build the path to the file pdbid = pdbid.lower() filename = f"{base_dir}/{pdbid[1:3]}/{pdbid}.cif.gz" if not os.path.exists(filename): raise ValueError(f"File {filename} does not exist") return filename
[docs] @immutable_lru_cache(maxsize=1000, deepcopy=True) def cached_parse(pdb_id: str, **kwargs) -> dict: """Wrapper around parse with caching to return an independent copy of the output dict.""" data = parse(get_pdb_mirror_path(pdb_id), config=ParseConfig(**kwargs) if kwargs else None) if "atom_array" not in data: assembly_ids = list(data["assemblies"].keys()) data["atom_array"] = data["assemblies"][assembly_ids[0]][0] data["pdb_id"] = pdb_id return data
[docs] def is_clash(atom_array_1: AtomArray, atom_array_2: AtomArray, clash_distance: float = 1.0) -> bool: """Check for clashes between two atom arrays.""" tree = cKDTree(atom_array_2.coord) return len(tree.query_ball_point(atom_array_1.coord, r=clash_distance, return_length=True).nonzero()[0]) > 0
[docs] def add_randomized_standard_annotations(atom_array: AtomArrayPlus) -> AtomArrayPlus: """Add randomized standard annotations to an AtomArray""" for sa_cls in STANDARD_ANNOTATIONS: if sa_cls in {C_CTR, C_NTR}: continue if sa_cls.n_body == 1: random_indices = np.random.choice(len(atom_array), size=5, replace=False) mask = np.zeros(len(atom_array), dtype=bool) mask[random_indices] = True sa_cls.set_mask(atom_array, mask) if not sa_cls.is_mask: annotation_shape = sa_cls.default_annotation(atom_array).shape modification_shape = (5, *annotation_shape[1:]) if np.issubdtype(sa_cls.dtype, bool): random_values = np.random.random(modification_shape) > 0.5 elif np.issubdtype(sa_cls.dtype, np.integer): random_values = np.random.randint(0, 100, size=modification_shape) else: random_values = np.random.random(modification_shape) annotation_arr = np.zeros(annotation_shape, dtype=sa_cls.dtype) annotation_arr[random_indices, ...] = random_values sa_cls.set_annotation(atom_array, array=annotation_arr) elif sa_cls.n_body == 2: random_indices = np.random.choice(len(atom_array), size=(5, 2), replace=False) random_bool_values = np.ones(5, dtype=bool) mask = AnnotationList2D( n_atoms=atom_array.array_length(), pairs=random_indices, values=random_bool_values, ) sa_cls.set_mask(atom_array, mask) if not sa_cls.is_mask: annotation_shape = sa_cls.default_annotation(atom_array).values.shape modification_shape = (5, *annotation_shape[1:]) if np.issubdtype(sa_cls.dtype, bool): vals = np.random.random(modification_shape) > 0.5 elif np.issubdtype(sa_cls.dtype, np.integer): vals = np.random.randint(0, 100, size=modification_shape).astype(sa_cls.dtype) else: vals = np.random.random(modification_shape) annotation_arr = AnnotationList2D( n_atoms=atom_array.array_length(), pairs=random_indices, values=vals, ) sa_cls.set_annotation( atom_array, pairs=annotation_arr.pairs, values=annotation_arr.values, ) # Byproduct of some StandardAnnotation defaults, but not expected to be preserved atom_array.del_annotation("within_chain_res_idx") return atom_array
[docs] def assert_tensor_or_array_equal( actual: np.ndarray | torch.Tensor | int | float | str | bool, expected: np.ndarray | torch.Tensor | int | float | str | bool, error_msg: str, atol: float = 1e-4, rtol: float = 1e-4, ) -> None: """Assert tensors, arrays, or scalars are equal with type-aware tolerance. Uses exact equality for int/bool, tolerances for float, and treats NaN as equal. Args: atol: Absolute tolerance for floats. Defaults to ``1e-4``. rtol: Relative tolerance for floats. Defaults to ``1e-4``. Raises: AssertionError: If values don't match. """ if torch.is_tensor(actual): if actual.dtype == torch.bool or actual.dtype in [torch.int32, torch.int64]: torch.testing.assert_close( actual, expected, atol=0, rtol=0, equal_nan=True, msg=lambda x: error_msg + ": " + x ) else: torch.testing.assert_close( actual, expected, atol=atol, rtol=rtol, equal_nan=True, msg=lambda x: error_msg + ": " + x ) elif isinstance(actual, np.ndarray): if actual.dtype.kind in ["U", "S"] or actual.dtype == bool or np.issubdtype(actual.dtype, np.integer): np.testing.assert_array_equal(actual, expected, err_msg=error_msg) else: np.testing.assert_allclose(actual, expected, atol=atol, rtol=rtol, equal_nan=True, err_msg=error_msg) else: # Handle numpy string conversion for scalars if isinstance(actual, np.str_): actual = str(actual) if isinstance(expected, np.str_): expected = str(expected) assert actual == expected, error_msg
[docs] def assert_equal( obtained: dict | list | np.ndarray | torch.Tensor | int | float | str | bool, expected: dict | list | np.ndarray | torch.Tensor | int | float | str | bool, path: str = "", allow_extra_keys: bool = False, atol: float = 1e-4, rtol: float = 1e-4, ) -> None: """Recursively assert nested structures are equal with type-aware comparison. Handles dicts, lists, tensors, arrays, and scalars. Uses :py:func:`assert_tensor_or_array_equal` for leaf value comparisons. Args: path: Current path in structure for error messages. Defaults to ``""``. allow_extra_keys: Allow extra keys in ``obtained`` dicts. Defaults to ``False``. atol: Absolute tolerance for floats. Defaults to ``1e-4``. rtol: Relative tolerance for floats. Defaults to ``1e-4``. Raises: AssertionError: If values don't match. """ error_msg = f"Mismatch at {path}" if path else "Values don't match" # Handle dictionaries recursively if isinstance(expected, dict): if not isinstance(obtained, dict): raise AssertionError(f"{error_msg}: expected dict, got {type(obtained)}") # Check keys missing = set(expected.keys()) - set(obtained.keys()) if missing: raise AssertionError(f"{error_msg}: missing keys {missing}") if not allow_extra_keys: extra = set(obtained.keys()) - set(expected.keys()) if extra: raise AssertionError(f"{error_msg}: extra keys {extra}") # Recurse on values for key in expected: new_path = f"{path}[{key!r}]" if path else repr(key) assert_equal(obtained[key], expected[key], new_path, allow_extra_keys, atol, rtol) return # Handle lists recursively if isinstance(expected, list): if not isinstance(obtained, list): raise AssertionError(f"{error_msg}: expected list, got {type(obtained)}") if len(obtained) != len(expected): raise AssertionError(f"{error_msg}: length mismatch {len(obtained)} != {len(expected)}") # Convert to numpy arrays for comparison if all elements are numeric try: obtained_arr = np.array(obtained) expected_arr = np.array(expected) # If conversion succeeded and types are comparable, use array comparison if obtained_arr.dtype.kind in ["i", "u", "f"] and expected_arr.dtype.kind in ["i", "u", "f"]: assert_tensor_or_array_equal(obtained_arr, expected_arr, error_msg, atol, rtol) return except (ValueError, TypeError): pass # Otherwise, compare element by element for i, (obtained_val, expected_val) in enumerate(zip(obtained, expected, strict=False)): new_path = f"{path}[{i}]" if path else f"[{i}]" assert_equal(obtained_val, expected_val, new_path, allow_extra_keys, atol, rtol) return # All leaf values (tensors, arrays, scalars) handled by assert_tensor_or_array_equal assert_tensor_or_array_equal(obtained, expected, error_msg, atol, rtol)
[docs] def save_and_load_atom_array( atom_array: AtomArrayPlus, extra_fields_to_load: list[str] | None = "all", extra_annotations_to_save: list[str] | None = "all", ) -> AtomArrayPlus: """Save an atom array to a temporary CIF and load it back.""" with TemporaryDirectory() as temp_dir: temp_file = Path(temp_dir) / "test.cif" to_cif_file( atom_array, temp_file, extra_fields=extra_annotations_to_save or [], save_standard_annotations=True, ) result = parse( temp_file, config=ParseConfig.from_preset( "minimal", load_standard_annotations=True, extra_fields=extra_fields_to_load or [] ), ) assembly_ids = list(result["assemblies"].keys()) loaded = result["assemblies"][assembly_ids[0]][0] CONDITIONS.fill_missing_conditions_with_defaults(loaded) return loaded