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