Implemented Natural Extended Reference Frame algorithm for adding the fourth atom based on the previous three coords, the new bond angle and new torsion angle
This commit is contained in:
parent
a21b8e97a7
commit
803cd66a34
50
src/geometry/nerf.py
Normal file
50
src/geometry/nerf.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import numpy as np
|
||||
|
||||
# "Practical conversion from torsion space to Cartesian space for in silico protein synthesis"
|
||||
# https://pubmed.ncbi.nlm.nih.gov/15898109/
|
||||
# Natural Extension Reference Frame algorithm
|
||||
def nerf(a, b, c, bond_length, bond_angle_deg, torsion_angle_deg):
|
||||
"""
|
||||
Parameters:
|
||||
- a, b, c: (3,) numpy arrays of xyz coordinates of the previous atoms.
|
||||
- bond_length: distance between c and d (Angstroms).
|
||||
- bond_angle_deg: angle b-c-d (Degrees).
|
||||
- torsion_angle_deg: dihedral angle a-b-c-d (Degrees).
|
||||
|
||||
Returns:
|
||||
- d: (3,) numpy array of coordinates for the new atom.
|
||||
"""
|
||||
# convert to radians
|
||||
bond_angle_rad = np.radians(bond_angle_deg)
|
||||
torsion_angle_rad = np.radians(torsion_angle_deg)
|
||||
|
||||
# firstly calculate the position of D in the local reference frame
|
||||
# the bond b-c is the local x axis
|
||||
|
||||
|
||||
bond_angle_complement = np.pi - bond_angle_rad
|
||||
cd_local = np.array([
|
||||
bond_length * np.cos(bond_angle_complement),
|
||||
bond_length * np.sin(bond_angle_complement) * np.cos(torsion_angle_rad),
|
||||
bond_length * np.sin(bond_angle_complement) * np.sin(torsion_angle_rad)
|
||||
])
|
||||
|
||||
# next few steps are converting from local frame to global frame
|
||||
bc = c - b
|
||||
bc_u = bc / np.linalg.norm(bc)
|
||||
ab = b - a
|
||||
|
||||
# normal to the plane a-b-c is the local z axis
|
||||
n = np.cross(ab, bc_u)
|
||||
n_u = n / np.linalg.norm(n)
|
||||
|
||||
# the local y axis is perpendicular to both the bond bc and the normal
|
||||
m_u = np.cross(n_u, bc_u)
|
||||
|
||||
# rotation matrix columns: [x_axis, y_axis, z_axis] (3,3)
|
||||
rotation_matrix = np.column_stack((bc_u, m_u, n_u))
|
||||
|
||||
# Global_Pos = Origin(C) + (Rotation * Local_Pos)
|
||||
d_global = c + np.dot(rotation_matrix, cd_local)
|
||||
|
||||
return d_global
|
||||
|
|
@ -17,7 +17,7 @@ GEO = {
|
|||
'C_N_H_angle': 119.0,
|
||||
}
|
||||
|
||||
# Legacy function checking if two atoms (implementation as CAs) are within threshold.
|
||||
# Legacy function checking if two atoms are within threshold.
|
||||
def check_clashes(new_coord, existing_coords, threshold=3.0):
|
||||
if len(existing_coords) == 0: return False
|
||||
diff = existing_coords - new_coord
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
import numpy as np
|
||||
|
||||
def place_atom_nerf(a, b, c, bond_length, bond_angle_deg, torsion_angle_deg):
|
||||
"""
|
||||
Calculates the coordinates of atom D given atoms A, B, C and internal coords.
|
||||
|
||||
Parameters:
|
||||
- a, b, c: (3,) numpy arrays of xyz coordinates of the previous atoms.
|
||||
- bond_length: distance between c and d (Angstroms).
|
||||
- bond_angle_deg: angle b-c-d (Degrees).
|
||||
- torsion_angle_deg: dihedral angle a-b-c-d (Degrees).
|
||||
|
||||
Returns:
|
||||
- d: (3,) numpy array of coordinates for the new atom.
|
||||
"""
|
||||
# 1. Convert to rad
|
||||
bond_angle_rad = np.radians(bond_angle_deg)
|
||||
torsion_angle_rad = np.radians(torsion_angle_deg)
|
||||
|
||||
# 2. Calculate the position of D in the local reference frame
|
||||
# We align the local X-axis with the bond B->C.
|
||||
# Note: The geometric calculation uses the complement of the bond angle
|
||||
# so we use pi - bond_angle.
|
||||
|
||||
# D_local represents the vector C->D in the local frame
|
||||
d_local = np.array([
|
||||
bond_length * np.cos(np.pi - bond_angle_rad),
|
||||
bond_length * np.sin(np.pi - bond_angle_rad) * np.cos(torsion_angle_rad),
|
||||
bond_length * np.sin(np.pi - bond_angle_rad) * np.sin(torsion_angle_rad)
|
||||
])
|
||||
|
||||
# 3. Build the transformation matrix from Local to Global Frame
|
||||
|
||||
# Vector from B to C (normalized) - this is our local X-axis
|
||||
bc = c - b
|
||||
bc_u = bc / np.linalg.norm(bc)
|
||||
|
||||
# Vector from A to B
|
||||
ab = b - a
|
||||
|
||||
# Normal to the plane A-B-C (normalized) - this is our local Z-axis
|
||||
# We use Cross Product to find the perpendicular vector
|
||||
n = np.cross(ab, bc_u)
|
||||
n_u = n / np.linalg.norm(n)
|
||||
|
||||
# The "Up" vector in the plane (normalized) - this is our local Y-axis
|
||||
# It is perpendicular to both the bond BC and the normal N
|
||||
m_u = np.cross(n_u, bc_u)
|
||||
|
||||
# Create the Rotation Matrix columns [x_axis, y_axis, z_axis]
|
||||
# Shape: (3, 3)
|
||||
rotation_matrix = np.column_stack((bc_u, m_u, n_u))
|
||||
|
||||
# 4. Transform D_local to Global Coordinates
|
||||
# Global_Pos = Origin(C) + (Rotation * Local_Pos)
|
||||
d_global = c + np.dot(rotation_matrix, d_local)
|
||||
|
||||
return d_global
|
||||
|
||||
# --- Simple Test Case ---
|
||||
if __name__ == "__main__":
|
||||
# Define 3 arbitrary starting atoms (A, B, C)
|
||||
atom_a = np.array([0.0, 1.0, 0.0])
|
||||
atom_b = np.array([0.0, 0.0, 0.0]) # Origin
|
||||
atom_c = np.array([1.5, 0.0, 0.0]) # Along X-axis
|
||||
|
||||
# Define internal coordinates for the next atom (D)
|
||||
length = 1.5 # Angstroms
|
||||
b_angle = 120.0 # Degrees
|
||||
torsion = 90.0 # Degrees (Should point "out" of the screen/plane)
|
||||
|
||||
new_atom = place_atom_nerf(atom_a, atom_b, atom_c, length, b_angle, torsion)
|
||||
|
||||
print(f"Atom A: {atom_a}")
|
||||
print(f"Atom B: {atom_b}")
|
||||
print(f"Atom C: {atom_c}")
|
||||
print(f"Atom D (Calculated): {np.round(new_atom, 3)}")
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import numpy as np
|
||||
|
||||
class RamachandranSampler:
|
||||
def __init__(self):
|
||||
|
||||
# Generalised for 18 amino acids. Weighted for beta strand region
|
||||
self.general_dist = [
|
||||
# beta strand (top left)
|
||||
{'phi_m': -120, 'phi_s': 20, 'psi_m': 140, 'psi_s': 20, 'w': 0.70},
|
||||
# right handed helix (bottom left)
|
||||
{'phi_m': -65, 'phi_s': 15, 'psi_m': -40, 'psi_s': 15, 'w': 0.25},
|
||||
# left handed helix (top right)
|
||||
{'phi_m': 60, 'phi_s': 15, 'psi_m': 40, 'psi_s': 15, 'w': 0.05}
|
||||
]
|
||||
|
||||
# Glycine more flexible
|
||||
self.glycine_dist = [
|
||||
# beta strand (top left)
|
||||
{'phi_m': -100, 'phi_s': 30, 'psi_m': 140, 'psi_s': 30, 'w': 0.3},
|
||||
# right handed helix (bottom left)
|
||||
{'phi_m': -60, 'phi_s': 30, 'psi_m': -30, 'psi_s': 30, 'w': 0.2},
|
||||
# left handed helix (top right)
|
||||
{'phi_m': 60, 'phi_s': 30, 'psi_m': 30, 'psi_s': 30, 'w': 0.2},
|
||||
# bottom right for glycine
|
||||
{'phi_m': 100, 'phi_s': 30, 'psi_m': -140,'psi_s': 30, 'w': 0.3}
|
||||
]
|
||||
|
||||
# Proline no left handed helix
|
||||
self.proline_dist = [
|
||||
# beta strand (top left)
|
||||
{'phi_m': -63, 'phi_s': 5, 'psi_m': 150, 'psi_s': 20, 'w': 0.8},
|
||||
# right handed helix (bottom left)
|
||||
{'phi_m': -63, 'phi_s': 5, 'psi_m': -35, 'psi_s': 20, 'w': 0.2}
|
||||
]
|
||||
|
||||
# returns the correct distribution
|
||||
def _get_distribution(self, res_name):
|
||||
if res_name == 'GLY':
|
||||
return self.glycine_dist
|
||||
elif res_name == 'PRO':
|
||||
return self.proline_dist
|
||||
else:
|
||||
return self.general_dist
|
||||
|
||||
# returns (phi, psi) for the residue (units degrees)
|
||||
def sample(self, res_name):
|
||||
# the possible regions to sample
|
||||
dist_options = self._get_distribution(res_name)
|
||||
|
||||
weights = [d['w'] for d in dist_options]
|
||||
# normalise the weights to ensure sum is exactly 1.0 (no float errors)
|
||||
weights = np.array(weights) / np.sum(weights)
|
||||
# choose a region based on the weights
|
||||
choice_idx = np.random.choice(len(dist_options), p=weights)
|
||||
selected = dist_options[choice_idx]
|
||||
|
||||
# do a gaussian sample to get some variation
|
||||
phi = np.random.normal(selected['phi_m'], selected['phi_s'])
|
||||
psi = np.random.normal(selected['psi_m'], selected['psi_s'])
|
||||
|
||||
# wrap angles to +-180 degrees
|
||||
phi = (phi + 180) % 360 - 180
|
||||
psi = (psi + 180) % 360 - 180
|
||||
|
||||
return phi, psi
|
||||
|
||||
# example
|
||||
if __name__ == "__main__":
|
||||
sampler = RamachandranSampler()
|
||||
|
||||
print("Sampling 5 residues...")
|
||||
for res in ['ALA', 'GLY', 'PRO', 'TRP', 'VAL']:
|
||||
phi, psi = sampler.sample(res)
|
||||
print(f"{res}: Phi={phi:.1f}, Psi={psi:.1f}")
|
||||
19
src/main.py
19
src/main.py
|
|
@ -1,15 +1,4 @@
|
|||
from backbone.ramachandran import RamachandranSampler
|
||||
from rotamers.dunbrack import DunbrackRotamerLibrary
|
||||
|
||||
rs = RamachandranSampler()
|
||||
rl = DunbrackRotamerLibrary()
|
||||
|
||||
res_name = "PHE"
|
||||
|
||||
print(f"Backbone torsion angles for {res_name}")
|
||||
phi, psi = rs.sample(res_name)
|
||||
print(f"phi: {phi}, psi: {psi}")
|
||||
|
||||
print(f"Sidechain rotamers for {res_name}")
|
||||
for rotamer in rl.rotamer_params(res_name, 100, 100):
|
||||
print(rotamer.p, rotamer.chis)
|
||||
# Get primary sequence i.e. "PHE", "SER", "THR", "PRO", "VAL"...
|
||||
# Build first residue including sidechain
|
||||
# Add ghost C' for first NeRF iteration
|
||||
# Using C
|
||||
|
|
|
|||
17
src/test.py
Normal file
17
src/test.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from backbone.ramachandran import RamachandranSampler
|
||||
from rotamers.dunbrack import DunbrackRotamerLibrary
|
||||
|
||||
rs = RamachandranSampler()
|
||||
rl = DunbrackRotamerLibrary()
|
||||
|
||||
res_name = "PHE"
|
||||
|
||||
print(f"Backbone torsion angles for {res_name}:")
|
||||
phi, psi = rs.sample(res_name)
|
||||
print(f"phi: {phi}, psi: {psi}")
|
||||
print(" ")
|
||||
|
||||
print(f"Sidechain rotamers for {res_name} (top 5):")
|
||||
for rotamer in rl.rotamer_params(res_name, 100, 100)[:5]:
|
||||
print(rotamer.p, rotamer.chis)
|
||||
print(" ")
|
||||
Loading…
Reference in New Issue
Block a user