Skip to content

Commit db890f6

Browse files
nscorleyNathaniel CorleyBuddha7771rcluneCroydon-Brixton
authored
docs: initial examples (#12)
* docs: load and visualize structures * chore: format * docs: update README * docs(readme): improve code block formatting * docs(readme): fix typos * Change TOC depth Just changed the TOC depth from 1 to 2 in the conf.py file to test the GitHub actions workflow. * Update README.md Updated the link to the external docs to remove 404 error. * Update README.md Changed the documentation link in the contribution section. * docs(readme): add notice * clean: remove legacy dependencies, relax various dependency versions, update CI (#4) * clean: remove `fire` and `fastparquet` legacy dependencies, relax `biotite`, `hydrid`, `torch` and `einops` versions * ci: update github actions trigger `digs` workflow manually only. * clean: clean-up CI (#6) * ci: restrict workflow permissions (#8) * clean: clean-up CI * ci: restrict workflow permissions * refactor: disentangle CLI form package code and document PDB training example * refactor: disentangle CLI from package code * docs: update readme with PDB training example * docs: further documentation improvements * Build/numpy version (#10) * refactor: disentangle CLI from package code * docs: update readme with PDB training example * docs: further documentation improvements * build: enable numpy 2.x, pandas 2.3 * chore: ruff * docs: fix readme (#11) * docs: two additional examples for the gallery --------- Co-authored-by: Nathaniel Corley <nscorley@Nathaniels-MacBook-Pro.local> Co-authored-by: Buddha7771 <hwlee7771@gmail.com> Co-authored-by: Rachel Clune <rclune4b@gmail.com> Co-authored-by: Rachel Clune <rachel.clune@omsf.io> Co-authored-by: Simon Mathis <simon.mathis@gmail.com> Co-authored-by: Kieran Didi <58345129+kierandidi@users.noreply.github.com>
1 parent b9cd188 commit db890f6

18 files changed

Lines changed: 860 additions & 177 deletions
479 KB
Loading
319 KB
Loading
204 KB
Loading
356 KB
Loading
201 KB
Loading
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
"""
2+
Annotating and Saving Protein Structures
3+
=========================================
4+
5+
This example walks through how to add custom annotations to AtomArrays, visualize them, and save them for later use.
6+
7+
**Prerequisites**: Familiarity with :doc:`load_and_visualize_structures` for basic structure loading and exploration.
8+
9+
.. figure:: /_static/examples/annotate_and_save_structures_01.png
10+
:alt: Heme pocket visualization
11+
:width: 400px
12+
13+
Visualization of heme-binding pocket atoms (within 6Å of heme ligand) in myoglobin.
14+
"""
15+
16+
########################################################################
17+
# Setup and Structure Loading
18+
# ----------------------------
19+
#
20+
# Let's start by loading a protein structure that we'll annotate. We'll use the same myoglobin structure from the loading example:
21+
22+
import os
23+
import tempfile
24+
25+
import biotite.structure as struc
26+
import numpy as np
27+
28+
from atomworks.io import parse
29+
from atomworks.io.utils.io_utils import to_cif_file
30+
from atomworks.io.utils.testing import get_pdb_path_or_buffer
31+
from atomworks.io.utils.visualize import view
32+
33+
# sphinx_gallery_thumbnail_path = '_static/examples/annotate_and_save_structures_01.png'
34+
35+
# Load myoglobin structure with heme
36+
example_pdb_id = "101m" # Myoglobin with heme
37+
pdb_path = get_pdb_path_or_buffer(example_pdb_id)
38+
39+
# Parse the structure (no need to add missing atoms, since we would just remove them in the following step)
40+
atom_array = parse(pdb_path, add_missing_atoms=False, fix_formal_charges=False)["assemblies"]["1"][0]
41+
42+
print(f"Loaded structure with {len(atom_array)} atoms")
43+
print(f"Chains: {np.unique(atom_array.chain_id)}")
44+
45+
# Clean up coordinates (remove any NaN values, if present)
46+
# (NaN coordinates will break our later step when we create a CellList with Biotite)
47+
valid_coords_mask = ~np.isnan(atom_array.coord).any(axis=1)
48+
atom_array = atom_array[valid_coords_mask]
49+
print(f"After removing NaN coordinates: {len(atom_array)} atoms")
50+
51+
########################################################################
52+
# Adding Custom Annotations
53+
# --------------------------
54+
#
55+
# Now let's add custom annotations to mark different types of atoms. We'll use pocket identification as an example to demonstrate how to create meaningful structural annotations for many ML and general bioinformatics applications.
56+
#
57+
# Step 1: Identify Structural Features (Pocket Identification)
58+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
59+
#
60+
# Let's efficiently identify the heme-binding pocket using spatial distance cutoffs with Biotite's ``CellList`` class:
61+
62+
# Find atoms within 6 Angstroms of the heme using a spatial cell list
63+
cell_list = struc.CellList(atom_array.coord, cell_size=6.0)
64+
heme_coords = atom_array.coord[atom_array.res_name == "HEM"]
65+
66+
print(f"Found {len(heme_coords)} heme atoms")
67+
68+
# Get all atoms within 6Å of any heme atom
69+
pocket_mask = cell_list.get_atoms(heme_coords, 6.0, as_mask=True)
70+
pocket_mask = np.any(pocket_mask, axis=0) # Combine results for all heme atoms
71+
72+
print(f"Found {np.sum(pocket_mask)} atoms within 6Å of heme")
73+
74+
# %%
75+
76+
# Visualize the pocket region (always a helpful sanity-check, and trivial with AtomWorks)
77+
print("\nVisualizing pocket region (all atoms within 6Å of heme):")
78+
view(atom_array[pocket_mask])
79+
80+
########################################################################
81+
# .. figure:: /_static/examples/annotate_and_save_structures_01.png
82+
# :alt: Heme pocket visualization
83+
84+
########################################################################
85+
# Step 2: Create Annotations from Identified Features
86+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
87+
#
88+
# Now we'll convert our pocket identification into an explicit ``AtomArray`` annotation and visualize it:
89+
90+
# Boolean annotation for pocket residues (excluding heme itself)
91+
is_pocket = pocket_mask & (atom_array.res_name != "HEM")
92+
atom_array.set_annotation("is_hem_pocket", is_pocket.astype(bool))
93+
94+
# Boolean annotation for heme atoms
95+
is_heme = atom_array.res_name == "HEM"
96+
atom_array.set_annotation("is_heme", is_heme.astype(bool))
97+
98+
print(f" - Pocket atoms: {np.sum(atom_array.is_hem_pocket)}")
99+
print(f" - Heme atoms: {np.sum(atom_array.is_heme)}")
100+
101+
# %%
102+
103+
# Visualize just the pocket residues
104+
print("\nVisualizing annotated pocket residues:")
105+
view(atom_array[atom_array.is_hem_pocket])
106+
107+
########################################################################
108+
# .. figure:: /_static/examples/annotate_and_save_structures_02.png
109+
# :alt: Annotated pocket residues visualization
110+
111+
########################################################################
112+
# Saving Annotated Structures
113+
# ----------------------------
114+
#
115+
# Now let's save our annotated structure. In many use cases we may want to save our modified ``AtomArray`` to disk and later load again, preserving our original annotations.
116+
#
117+
# AtomWorks provides two methods to do so:
118+
#
119+
# .. list-table::
120+
# :header-rows: 0
121+
#
122+
# * - Saving to CIF, adding extra annotations directly into the file
123+
# * - Standard Python object pickling (which may be sensitive to versions, libraries, etc.)
124+
#
125+
# Saving to CIF Files
126+
# ~~~~~~~~~~~~~~~~~~~
127+
#
128+
# CIF files are the standard for structural data and allow us to store arbitrary annotations and categories.
129+
130+
# Create temporary directory for our files
131+
temp_dir = tempfile.mkdtemp()
132+
print(f"Working in temporary directory: {temp_dir}")
133+
134+
# Save to CIF file with custom annotations specified
135+
cif_path = os.path.join(temp_dir, "annotated_structure.cif")
136+
custom_fields = ["is_hem_pocket", "is_heme"]
137+
138+
saved_cif_path = to_cif_file(
139+
atom_array,
140+
cif_path,
141+
extra_fields=custom_fields,
142+
)
143+
144+
print(f"Saved CIF file to: {saved_cif_path}")
145+
print(f"File size: {os.path.getsize(saved_cif_path) / 1024:.1f} KB")
146+
147+
########################################################################
148+
# Note on Biological Assemblies and CIF Saving
149+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
150+
#
151+
# In some cases, you may find that ``to_cif_file`` reports an error when the structure represents a biological assembly containing multiple copies of the asymmetric unit. The reason for this error is that ``AtomWorks`` builds the biological assembly and explicitly represents every atom; we can't then reverse that process since we may be left with ambiguous bond annotations (e.g., no way to distinguish between multiple copies of "Chain A"). The best solution is to either (a) set the ``chain_id`` to the ``chain_iid`` (which resolves the ambiguity) or (b) simply save the object using a pickle.
152+
#
153+
# More rigorous solutions exist; a helpful place for contributions!
154+
#
155+
# Alternative Storage Options
156+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~
157+
#
158+
# For Python-specific workflows, you can also save structures as pickle files to preserve exact data types, though CIF files are recommended for interoperability and long-term storage.
159+
160+
########################################################################
161+
# Loading Annotated Structures
162+
# -----------------------------
163+
#
164+
# When we load pickled ``AtomArray``'s, we should restore our original object out-of-the-box with all annotations preserved.
165+
#
166+
# When loading from CIF, however, we may need to grapple with data type issues, since within CIF files all fields are considered strings.
167+
#
168+
# In the future, we would like to automatically detect annotation data types during loading (and/or allow specification of data types) - we would love contributions and a PR!
169+
#
170+
# Loading from CIF Files
171+
# ~~~~~~~~~~~~~~~~~~~~~~
172+
173+
from atomworks.io.utils.io_utils import load_any
174+
175+
# Load from CIF file
176+
loaded_from_cif = load_any(saved_cif_path, extra_fields="all")[0]
177+
178+
print("Loaded from CIF file:")
179+
print(f" Atoms: {len(loaded_from_cif)}")
180+
print(" Custom annotations:")
181+
for annotation in loaded_from_cif.get_annotation_categories():
182+
if annotation in custom_fields:
183+
dtype = getattr(loaded_from_cif, annotation).dtype
184+
print(f" ✓ {annotation} ({dtype})")
185+
186+
########################################################################
187+
# Handling Data Type Conversions
188+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
189+
#
190+
# As we can see above, when boolean annotations are saved to CIF files, they become string representations ("True"/"False"). Here's how to convert them back (we welcome contributions to automate this process and/or allow explicit specification):
191+
192+
193+
# Convert string booleans back to actual boolean type
194+
def fix_boolean_annotation(atom_array: struc.AtomArray, annotation_name: str) -> struc.AtomArray:
195+
"""Convert string boolean annotations back to bool type."""
196+
string_values = getattr(atom_array, annotation_name)
197+
boolean_values = string_values == "True"
198+
atom_array.del_annotation(annotation_name)
199+
atom_array.set_annotation(annotation_name, boolean_values)
200+
return atom_array
201+
202+
203+
# Fix boolean annotations
204+
loaded_from_cif = fix_boolean_annotation(loaded_from_cif, "is_hem_pocket")
205+
loaded_from_cif = fix_boolean_annotation(loaded_from_cif, "is_heme")
206+
207+
print("\nAfter conversion:")
208+
print(f" is_hem_pocket: {loaded_from_cif.is_hem_pocket.dtype}, {np.sum(loaded_from_cif.is_hem_pocket)} True values")
209+
print(f" is_heme: {loaded_from_cif.is_heme.dtype}, {np.sum(loaded_from_cif.is_heme)} True values")
210+
print(f" Sample values: {loaded_from_cif.is_hem_pocket[:3]}")
211+
212+
# %%
213+
214+
# Clean up temporary files
215+
import shutil
216+
217+
shutil.rmtree(temp_dir)
218+
print(f"✓ Cleaned up temporary directory: {temp_dir}")
219+
print("✓ Successfully demonstrated structure annotation, saving, and loading!")
220+
221+
########################################################################
222+
# Related Examples
223+
# ----------
224+
#
225+
# - :doc:`pocket_conditioning_transform` - Create custom transforms for ligand pocket identification and ML feature generation

docs/examples/basics.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""
2+
Structure Manipulation Basics
3+
==============================
4+
5+
This example demonstrates how to use the ``atomworks.io`` package to load, inspect, and manipulate mmCIF structure files.
6+
You'll see how to parse a structure, visualize it, and perform basic analysis.
7+
8+
Similar content is covered in :doc:`load_and_visualize_structures`, but here we provide a more concise overview.
9+
"""
10+
11+
########################################################################
12+
# Loading from mmCIF
13+
# ==================
14+
#
15+
# We start by loading a structure from an mmCIF file using ``atomworks.io.parse``. This function supports various file formats and allows you to specify options such as which assembly to build, whether to add missing atoms, and more.
16+
17+
from atomworks.io import parse
18+
from atomworks.io.utils.testing import get_pdb_path_or_buffer
19+
from atomworks.io.utils.visualize import view
20+
21+
result_dict = parse(
22+
filename=get_pdb_path_or_buffer("6lyz"),
23+
build_assembly=["1"],
24+
add_missing_atoms=True,
25+
remove_waters=True,
26+
hydrogen_policy="remove",
27+
model=1,
28+
)
29+
30+
print("Keys in parsed result:", list(result_dict.keys()))
31+
32+
########################################################################
33+
# Visualizing the asymmetric unit and assembly
34+
# =============================================
35+
#
36+
# You can visualize the asymmetric unit or any assembly using the built-in viewer from ``atomworks.io``. This is helpful for quickly inspecting the structure and its components.
37+
38+
asym_unit = result_dict["asym_unit"][0]
39+
asym_unit = asym_unit[asym_unit.occupancy > 0]
40+
view(asym_unit)
41+
42+
########################################################################
43+
44+
assembly = result_dict["assemblies"]["1"][0]
45+
assembly = assembly[assembly.occupancy > 0]
46+
view(assembly)
47+
48+
########################################################################
49+
# Inspecting structure metadata
50+
# =============================
51+
#
52+
# The parsed result contains rich metadata, including chain and ligand information, as well as annotation categories. This information is useful for downstream analysis and filtering.
53+
54+
print("Chain info:", result_dict["chain_info"])
55+
print("Ligand info:", result_dict["ligand_info"])
56+
print("Metadata:", result_dict["metadata"])
57+
print("Annotation categories:", result_dict["asym_unit"][0].get_annotation_categories())
58+
59+
########################################################################
60+
# Manipulating AtomArray
61+
# ======================
62+
#
63+
# You can easily extract coordinates for specific atoms or chains, and inspect bond information. This is useful for custom analysis or feature extraction.
64+
65+
ca = assembly[(assembly.atom_name == "CA") & (assembly.occupancy > 0)]
66+
print("Coordinates of all resolved CA atoms:", ca.coord.shape)
67+
68+
chain = assembly[assembly.chain_id == "A"]
69+
print("Coordinates of chain A (all heavy atoms):", chain.coord.shape)
70+
71+
print("Bond array:", assembly.bonds.as_array())
72+
73+
########################################################################
74+
# Distance computations
75+
# =====================
76+
#
77+
# ``biotite.structure`` provides convenient functions for distance calculations between atoms or sets of atoms. Here we compute distances between C-alpha atoms.
78+
79+
import biotite.structure as struc
80+
81+
distance = struc.distance(ca.coord[0], ca.coord[1])
82+
print(f"Distance between first two C-alpha atoms: {distance:.2f} Å")
83+
84+
distance = struc.distance(ca[0], ca)
85+
print(f"Distances between first C-alpha atom and all other C-alpha atoms: {distance}")
86+
87+
########################################################################
88+
# Efficient neighbor search with CellList
89+
# ========================================
90+
#
91+
# For efficient spatial queries, use ``CellList`` to find atoms within a certain radius. This is useful for contact analysis and neighborhood queries.
92+
93+
resolved_atom_array = assembly[assembly.occupancy > 0]
94+
cell_list = struc.CellList(resolved_atom_array, cell_size=5.0)
95+
96+
near_atoms = cell_list.get_atoms(resolved_atom_array[0].coord, radius=4)
97+
print(f"Number of atoms within 7 Å of the first atom: {near_atoms.shape[0]}")
98+
print(f"Atom indices: {near_atoms}")
99+
print(f"Chain IDs: {resolved_atom_array.chain_id[near_atoms]}")
100+
print(f"Residue IDs: {resolved_atom_array.res_id[near_atoms]}")
101+
print(f"Residue names: {resolved_atom_array.res_name[near_atoms]}")
102+
103+
########################################################################
104+
# Next Steps
105+
# ----------
106+
#
107+
# Now that you've learned the basics of structure manipulation, you can explore more advanced topics:
108+
#
109+
# - :doc:`load_and_visualize_structures` - Comprehensive guide to loading and exploring protein structures
110+
# - :doc:`annotate_and_save_structures` - Learn how to add custom annotations to structures and save them
111+
# - :doc:`pocket_conditioning_transform` - Create custom transforms for ligand pocket identification and ML feature generation

0 commit comments

Comments
 (0)