|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| """
|
| This script implements protein structure alignment (CA atoms) using the
|
| Kabsch algorithm to compute optimal rotation and RMSD.
|
|
|
| References:
|
| - Kabsch W. (1976, 1978) A solution for the best rotation to relate two sets of vectors. Acta Crystallographica A.
|
| """
|
|
|
| import numpy as np
|
| from Bio import PDB
|
|
|
|
|
| def get_coordinates(structure, chain_id=None):
|
| """
|
| Extract the coordinates of alpha carbon (CA) atoms from a protein structure.
|
|
|
| Args:
|
| structure (Bio.PDB.Structure.Structure): A protein structure object parsed by Bio.PDB.PDBParser.
|
| chain_id (str, optional): The ID of the specific protein chain to extract coordinates from.
|
| If None, coordinates are extracted from all chains. Defaults to None.
|
|
|
| Returns:
|
| numpy.ndarray: A 2D array where each row represents the 3D coordinates of a CA atom.
|
| """
|
| coords = []
|
| for model in structure:
|
| for chain in model:
|
| if chain_id is not None and chain.id != chain_id:
|
| continue
|
| for residue in chain:
|
| for atom in residue:
|
| if atom.get_name() == "CA":
|
| coords.append(atom.get_coord())
|
| return np.array(coords)
|
|
|
|
|
| def kabsch_algorithm(P, Q):
|
| """
|
| Apply the Kabsch algorithm to find the optimal rotation matrix that aligns two sets of points.
|
|
|
| Args:
|
| P (numpy.ndarray): The first set of points with shape (N, 3)
|
| Q (numpy.ndarray): The second set of points with shape (N, 3)
|
|
|
| Returns:
|
| tuple: A tuple containing the rotation matrix (R), centroid of P (C_P), and centroid of Q (C_Q).
|
| """
|
|
|
| C_P = np.mean(P, axis=0)
|
| C_Q = np.mean(Q, axis=0)
|
|
|
|
|
| P_centered = P - C_P
|
| Q_centered = Q - C_Q
|
|
|
|
|
| H = np.dot(P_centered.T, Q_centered)
|
|
|
| try:
|
|
|
| U, S, Vt = np.linalg.svd(H)
|
|
|
|
|
| R = np.dot(Vt.T, U.T)
|
|
|
|
|
| if np.linalg.det(R) < 0:
|
| Vt[-1, :] *= -1
|
| R = np.dot(Vt.T, U.T)
|
|
|
| except np.linalg.LinAlgError:
|
| print("Warning: SVD did not converge. Returning identity rotation.")
|
| R = np.eye(3)
|
|
|
| return R, C_P, C_Q
|
|
|
|
|
| def calculate_rmsd(P, Q):
|
| diff = P - Q
|
| return np.sqrt(np.sum(diff * diff) / len(P))
|
|
|
|
|
| def _is_standard_residue(residue):
|
| hetflag = residue.id[0]
|
| return hetflag == " "
|
|
|
|
|
| def _choose_altloc(atom_list):
|
| """Pick one altloc variant for a duplicated atom (e.g., CA).
|
| Preference order: highest occupancy; tie-breaker: altloc 'A' or blank.
|
| """
|
| if len(atom_list) == 1:
|
| return atom_list[0]
|
| best = max(
|
| atom_list,
|
| key=lambda a: (
|
| a.get_occupancy() or 0.0,
|
| 1 if a.get_altloc() in ("A", " ") else 0,
|
| ),
|
| )
|
| return best
|
|
|
|
|
| def _residue_key(chain, residue):
|
| """Build a stable residue key using (chain_id, resseq, icode)."""
|
| het, resseq, icode = residue.get_id()
|
| return (chain.id, int(resseq), (icode or "").strip())
|
|
|
|
|
| def _collect_ca_coords(structure, chain_ids=None):
|
| """
|
| Collect CA coordinates keyed by (chain_id, resseq, icode).
|
|
|
| Returns
|
| -------
|
| dict[(chain_id, resseq, icode)] -> np.ndarray shape (3,), float64
|
|
|
| Parameters
|
| ----------
|
| structure : Bio.PDB.Structure.Structure
|
| chain_ids : Iterable[str] | None Select multiple chains.
|
| """
|
| chain_id_set = set(chain_ids) if chain_ids is not None else None
|
|
|
| idx = {}
|
| for model in structure:
|
| for chain in model:
|
| if chain_id_set is not None:
|
| if chain.id not in chain_id_set:
|
| continue
|
|
|
| for res in chain:
|
| if not _is_standard_residue(res):
|
| continue
|
| ca_atoms = [a for a in res if a.get_name() == "CA"]
|
| if not ca_atoms:
|
| continue
|
| ca = _choose_altloc(ca_atoms)
|
| key = _residue_key(chain, res)
|
| idx[key] = ca.get_coord().astype(np.float64)
|
| return idx
|
|
|
|
|
| def align_and_calculate_rmsd(file1, file2):
|
| """
|
| Align two protein structures based on their CA atoms and calculate RMSD.
|
|
|
| Args:
|
| file1 (str): Path to the first PDB file.
|
| file2 (str): Path to the second PDB file.
|
|
|
| Returns:
|
| float or None: The RMSD value between the aligned structures.
|
| Returns None if the number of CA atoms in the two structures differs.
|
| """
|
| parser = PDB.PDBParser(QUIET=True)
|
| structure1 = parser.get_structure("structure1", file1)
|
| structure2 = parser.get_structure("structure2", file2)
|
|
|
| coords1 = get_coordinates(structure1)
|
| coords2 = get_coordinates(structure2)
|
|
|
| if len(coords1) != len(coords2):
|
| print(
|
| "[WARNING] The lengths of coord1 and coord2 are different. There may exist missing atoms!"
|
| )
|
| orig_num_atoms = len(coords1), len(coords2)
|
| coords1 = _collect_ca_coords(structure1)
|
| coords2 = _collect_ca_coords(structure2)
|
|
|
| common_keys = sorted(set(coords1.keys()) & set(coords2.keys()))
|
| if len(common_keys) < 3:
|
| print(f"[WARNING] common CA pairs < 3 (got {len(common_keys)}). ")
|
| return None
|
| coords1 = np.vstack([coords1[k] for k in common_keys])
|
| coords2 = np.vstack([coords2[k] for k in common_keys])
|
| matched_num_atoms = len(coords1), len(coords2)
|
| print(
|
| f"Orig num atoms: {orig_num_atoms} Matched num atoms: {matched_num_atoms}"
|
| )
|
|
|
| R, C_P, C_Q = kabsch_algorithm(coords1, coords2)
|
|
|
|
|
| coords2_aligned = np.dot(coords2 - C_Q, R) + C_P
|
|
|
| rmsd = calculate_rmsd(coords1, coords2_aligned)
|
| return rmsd
|
|
|
|
|
| def Binder_align_and_calculate_rmsd(file1, file2, chain_id):
|
| """
|
| Align two protein structures based on their CA atoms, with one structure's specific chain, and calculate RMSD.
|
|
|
| Args:
|
| file1 (str): Path to the first PDB file.
|
| file2 (str): Path to the second PDB file.
|
| chain_id (str): The ID of the specific protein chain to extract coordinates from.
|
|
|
| Returns:
|
| float or None: The RMSD value between the aligned structures.
|
| Returns None if the number of CA atoms in the two structures differs.
|
| """
|
| parser = PDB.PDBParser(QUIET=True)
|
| structure1 = parser.get_structure("structure1", file1)
|
| structure2 = parser.get_structure("structure2", file2)
|
|
|
| coords1 = get_coordinates(structure1)
|
| coords2 = get_coordinates(structure2, chain_id)
|
| if len(coords1) != len(coords2):
|
| print(
|
| "[WARNING] The lengths of coord1 and coord2 are different. There may exist missing atoms!"
|
| )
|
| return None
|
|
|
| R, C_P, C_Q = kabsch_algorithm(coords1, coords2)
|
|
|
|
|
| coords2_aligned = np.dot(coords2 - C_Q, R) + C_P
|
|
|
| rmsd = calculate_rmsd(coords1, coords2_aligned)
|
| return rmsd
|
|
|
|
|
| def _list_chain_ids(structure):
|
| """Return chain IDs in file order (first model only)."""
|
| model = next(structure.get_models())
|
| return [ch.id for ch in model]
|
|
|
|
|
| def _coords_for_chain_ids(structure, chain_ids):
|
| """Stack CA coords for the given chain IDs (skip empty chains safely)."""
|
| chunks = []
|
| for cid in chain_ids:
|
| arr = get_coordinates(structure, chain_id=cid)
|
| if arr.size:
|
| chunks.append(arr)
|
| if not chunks:
|
| return np.empty((0, 3), dtype=float)
|
| return np.vstack(chunks)
|
|
|
|
|
| def align_and_calculate_target_rmsd(file1, file2, n=None):
|
| parser = PDB.PDBParser(QUIET=True)
|
| structure1 = parser.get_structure("structure1", file1)
|
| structure2 = parser.get_structure("structure2", file2)
|
|
|
| chains1 = _list_chain_ids(structure1)
|
| chains2 = _list_chain_ids(structure2)
|
|
|
| if n is None:
|
| n = len(chains1)
|
| if n > len(chains2):
|
| print(f"[WARNING] file2 has only {len(chains2)} chains; capping n to that.")
|
| n = len(chains2)
|
|
|
| ids1 = chains1[:n]
|
| ids2 = chains2[:n]
|
|
|
| coords1 = _coords_for_chain_ids(structure1, ids1)
|
| coords2 = _coords_for_chain_ids(structure2, ids2)
|
|
|
| if len(coords1) != len(coords2):
|
| print(
|
| "[WARNING] The lengths of coord1 and coord2 are different. "
|
| "Trying residue-key matching fallback."
|
| )
|
|
|
| idx1 = _collect_ca_coords(structure1, chain_ids=ids1)
|
| idx2 = _collect_ca_coords(structure2, chain_ids=ids2)
|
| common_keys = sorted(set(idx1.keys()) & set(idx2.keys()))
|
| if len(common_keys) < 3:
|
| print(f"[WARNING] common CA pairs < 3 (got {len(common_keys)}).")
|
| return None
|
| coords1 = np.vstack([idx1[k] for k in common_keys])
|
| coords2 = np.vstack([idx2[k] for k in common_keys])
|
| print(f"Matched num CA atoms after fallback: {(len(coords1), len(coords2))}")
|
|
|
| R, C_P, C_Q = kabsch_algorithm(coords1, coords2)
|
|
|
|
|
| coords2_aligned = np.dot(coords2 - C_Q, R) + C_P
|
|
|
| rmsd = calculate_rmsd(coords1, coords2_aligned)
|
| return rmsd
|
|
|