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:
Joel Wallace 2025-11-15 22:01:30 +00:00
parent a21b8e97a7
commit 803cd66a34
6 changed files with 72 additions and 167 deletions

50
src/geometry/nerf.py Normal file
View 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

View File

@ -17,7 +17,7 @@ GEO = {
'C_N_H_angle': 119.0, '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): def check_clashes(new_coord, existing_coords, threshold=3.0):
if len(existing_coords) == 0: return False if len(existing_coords) == 0: return False
diff = existing_coords - new_coord diff = existing_coords - new_coord

View File

@ -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)}")

View File

@ -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}")

View File

@ -1,15 +1,4 @@
from backbone.ramachandran import RamachandranSampler # Get primary sequence i.e. "PHE", "SER", "THR", "PRO", "VAL"...
from rotamers.dunbrack import DunbrackRotamerLibrary # Build first residue including sidechain
# Add ghost C' for first NeRF iteration
rs = RamachandranSampler() # Using C
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)

17
src/test.py Normal file
View 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(" ")