File size: 9,268 Bytes
dbf4184 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | """
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))
|