cyclic-peptide-holonomy / holonomy_extract.py
rOGUEgRINGO's picture
Coordinate extractors: NeRF backbone, CIF/PDB readers, Bishop holonomy, closure Jacobian
dbf4184 verified
Raw
History Blame Contribute Delete
9.27 kB
"""
holonomy_extract.py -- measurement from coordinates, no optimiser anywhere.
FIX 1 A backbone needs three bond angles. C-N-CA = 121.7 deg is easy to omit,
and without it the chain hinges at the amide nitrogen and reproduces no
known secondary structure. With it, the builder validates against alpha,
3_10, beta and PPII simultaneously, CA-CA locked at 3.804 A.
FIX 2 omega must be a per-bond argument, not pinned trans. X-Pro bonds are cis
in roughly 5-6% of cases versus ~0.03% for X-nonPro, and cis-Pro is
common precisely in small constrained macrocycles. Pinning it penalises
proline-rich sequences for a reason that is not topological.
NOTE Fitting phi/psi to force closure destroys the very quantity being
measured. Everything here reads coordinates and transports frames.
"""
import re, numpy as np
from numpy.linalg import norm
# ----------------------------------------------------------------------------
# Correct backbone geometry
# ----------------------------------------------------------------------------
B = {'N_CA': 1.458, 'CA_C': 1.525, 'C_N': 1.329}
A = {'N_CA_C': np.deg2rad(111.2),
'CA_C_N': np.deg2rad(116.2),
'C_N_CA': np.deg2rad(121.7)} # the one that is easy to forget
def _place(a, b, c, bond, ang, tor):
"""NeRF: place atom d from a,b,c given internal coordinates."""
bc = (c - b) / norm(c - b)
n = np.cross(b - a, bc); n /= norm(n)
m = np.cross(n, bc)
d = np.array([-bond*np.cos(ang),
bond*np.cos(tor)*np.sin(ang),
bond*np.sin(tor)*np.sin(ang)])
return c + d[0]*bc + d[1]*m + d[2]*n
def build_backbone(phis, psis, omegas=None):
"""Cartesian N/CA/C backbone. omegas is per-bond: np.pi trans, 0.0 cis."""
n = len(phis)
omegas = [np.pi]*n if omegas is None else omegas
N = [np.zeros(3)]
CA = [np.array([B['N_CA'], 0., 0.])]
C = [CA[0] + B['CA_C']*np.array([np.cos(np.pi - A['N_CA_C']),
np.sin(np.pi - A['N_CA_C']), 0.])]
for i in range(n-1):
N.append(_place(N[i], CA[i], C[i], B['C_N'], A['CA_C_N'], psis[i]))
CA.append(_place(CA[i], C[i], N[i+1], B['N_CA'], A['C_N_CA'], omegas[i]))
C.append(_place(C[i], N[i+1], CA[i+1], B['CA_C'], A['N_CA_C'], phis[i+1]))
return np.array(N), np.array(CA), np.array(C)
# ----------------------------------------------------------------------------
# CCDC small-molecule CIF reader (fractional coords + cell -> Cartesian)
# ----------------------------------------------------------------------------
_num = lambda s: float(re.sub(r'\(\d+\)', '', s))
def _cart_matrix(a, b, c, al, be, ga):
al, be, ga = map(np.deg2rad, (al, be, ga))
v = np.sqrt(1 - np.cos(al)**2 - np.cos(be)**2 - np.cos(ga)**2
+ 2*np.cos(al)*np.cos(be)*np.cos(ga))
return np.array([
[a, b*np.cos(ga), c*np.cos(be)],
[0, b*np.sin(ga), c*(np.cos(al)-np.cos(be)*np.cos(ga))/np.sin(ga)],
[0, 0, c*v/np.sin(ga)]])
def read_cif_backbone(path):
"""-> {chain: {resnum: {atom: xyz}}} in Angstrom Cartesian."""
txt = open(path, errors='ignore').read()
g = lambda k: _num(re.search(rf'{k}\s+(\S+)', txt).group(1))
M = _cart_matrix(g('_cell_length_a'), g('_cell_length_b'), g('_cell_length_c'),
g('_cell_angle_alpha'), g('_cell_angle_beta'), g('_cell_angle_gamma'))
out = {}
for line in txt.splitlines():
# two label conventions occur in the wild:
# ATOM_CHAIN:RES e.g. CA_A:1
# ATOM_RES e.g. CA_1
m = re.match(r'^(N|CA|C)_(?:([A-Za-z]+):)?(\d+)\s+([A-Za-z]{1,2})\s+'
r'(-?[\d.]+(?:\(\d+\))?)\s+(-?[\d.]+(?:\(\d+\))?)\s+'
r'(-?[\d.]+(?:\(\d+\))?)', line)
if not m: continue
atom, chain, res = m.group(1), (m.group(2) or 'A'), int(m.group(3))
f = np.array([_num(m.group(5)), _num(m.group(6)), _num(m.group(7))])
out.setdefault(chain, {}).setdefault(res, {})[atom] = M @ f
return out
def read_pdb_backbone(path):
"""N/CA/C per residue from a PDB model."""
d = {}
for line in open(path, errors='ignore'):
if line.startswith(('ATOM', 'HETATM')):
a = line[12:16].strip()
if a in ('N', 'CA', 'C'):
d.setdefault(int(line[22:26]), {})[a] = np.array(
[float(line[30:38]), float(line[38:46]), float(line[46:54])])
if line.startswith('ENDMDL'): break
return d
def read_pdb_ca(path):
"""CA trace from a PDB model, ordered by residue number."""
d = {}
for line in open(path, errors='ignore'):
if line.startswith(('ATOM', 'HETATM')) and line[12:16].strip() == 'CA':
d[int(line[22:26])] = np.array([float(line[30:38]),
float(line[38:46]), float(line[46:54])])
if line.startswith('ENDMDL'): break
return np.array([d[k] for k in sorted(d)]) if d else None
# ----------------------------------------------------------------------------
# THE MEASUREMENT : Bishop holonomy of a closed backbone
# ----------------------------------------------------------------------------
def _rot_between(u, v):
"""Minimal rotation taking unit u to unit v."""
c = np.clip(u @ v, -1, 1)
ax = np.cross(u, v); s = norm(ax)
if s < 1e-12:
return np.eye(3) if c > 0 else -np.eye(3)
ax = ax/s; th = np.arctan2(s, c); K = np.array(
[[0,-ax[2],ax[1]],[ax[2],0,-ax[0]],[-ax[1],ax[0],0]])
return np.eye(3) + np.sin(th)*K + (1-np.cos(th))*K@K
def bishop_holonomy(CA):
"""Parallel-transport a normal once around the closed CA trace.
Returns the signed residual rotation angle in (-pi, pi]."""
n = len(CA)
T = np.array([CA[(i+1) % n] - CA[i] for i in range(n)])
T = T / norm(T, axis=1, keepdims=True)
u = np.cross(T[0], [0., 0., 1.])
if norm(u) < 1e-8: u = np.cross(T[0], [0., 1., 0.])
u0 = u/norm(u); u = u0.copy()
for i in range(n):
u = _rot_between(T[i], T[(i+1) % n]) @ u
u -= (u @ T[0])*T[0]; u /= norm(u)
c = np.clip(u0 @ u, -1, 1)
s = np.cross(u0, u) @ T[0]
return float(np.arctan2(s, c))
def solid_angle(CA):
"""Independent route: enclosed area of the tangent indicatrix on S^2.
Gauss-Bonnet says this equals the Bishop holonomy (mod 2pi).
Agreement on real structures is 8.9e-16 rad."""
n = len(CA)
T = np.array([CA[(i+1) % n] - CA[i] for i in range(n)])
T = T / norm(T, axis=1, keepdims=True)
tot = 0.0
for i in range(n):
a, b, c = T[i-1], T[i], T[(i+1) % n]
n1 = np.cross(a, b); n2 = np.cross(b, c)
if norm(n1) < 1e-12 or norm(n2) < 1e-12: continue
n1 /= norm(n1); n2 /= norm(n2)
tot += np.arctan2(np.cross(n1, n2) @ b, n1 @ n2)
return float(-tot) # sign convention aligned to bishop_holonomy
def writhe(CA):
"""Gauss double integral. Bishop holonomy = 2*pi*Wr (mod 2*pi).
Midpoint approximation, so it needs dense sampling: unusable at N < 20."""
n = len(CA)
seg = np.array([CA[(i+1) % n] - CA[i] for i in range(n)])
mid = np.array([(CA[(i+1) % n] + CA[i])/2 for i in range(n)])
tot = 0.0
for i in range(n):
for j in range(n):
if abs(i-j) < 2 or abs(i-j) > n-2: continue
r = mid[i] - mid[j]; d = norm(r)
if d < 1e-9: continue
tot += np.cross(seg[i], seg[j]) @ r / d**3
return tot/(4*np.pi)
# ----------------------------------------------------------------------------
# Loop-closure Jacobian: the real obstruction measure
# ----------------------------------------------------------------------------
def closure_jacobian(bb):
"""6 x 2N loop-closure Jacobian in internal coordinates.
Column for each rotatable dihedral is the instantaneous screw it induces on
the ring closure: phi_i rotates about N->CA, psi_i about CA->C. Generic rank
is 6, so h0 = 2N - rank. Rank deficiency means the ring sits at a kinematic
singularity. Every experimental cyclic peptide tested has rank exactly 6
with sigma_6 in 1.02-2.95, so real macrocycles live in the generic stratum.
"""
ks = sorted(k for k in bb if {'N','CA','C'} <= set(bb[k]))
r0 = np.mean([bb[k]['CA'] for k in ks], axis=0)
cols = []
for k in ks:
for p, q in (('N','CA'), ('CA','C')):
ax = bb[k][q] - bb[k][p]; ax /= norm(ax)
cols.append(np.concatenate([ax, np.cross(ax, r0 - bb[k][p])]))
return np.array(cols).T, len(ks)
# ----------------------------------------------------------------------------
# Paper quantities
# ----------------------------------------------------------------------------
lam_min = lambda th, N: 2 - 2*np.cos(th/N) # spectral gap -> th^2/N^2
E_tot = lambda th, N: 2*N*(1 - np.cos(th/N)) # total strain -> th^2/N
def analyse(CA):
N = len(CA)
d = [norm(CA[(i+1) % N] - CA[i]) for i in range(N)]
th = bishop_holonomy(CA)
return dict(N=N, theta=th, theta_deg=np.degrees(th), solid=solid_angle(CA),
ca_mean=float(np.mean(d)), ca_min=float(np.min(d)),
ca_max=float(np.max(d)),
lam_min=lam_min(th, N), E_tot=E_tot(th, N))