Coordinate extractors: NeRF backbone, CIF/PDB readers, Bishop holonomy, closure Jacobian
dbf4184 verified | """ | |
| 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)) | |