| """Where in Ramachandran space does a uniform backbone close into a ring? |
| |
| A uniform (phi,psi) generates a helix: the residue transform is a screw with |
| rotation Omega about an axis and a rise d along it. The chain closes into a |
| flat N-ring iff rise = 0 and Omega = 2*pi*k/N for some winding number k. |
| |
| So closure is not a free parameter. It is a LOCUS, one curve per (N,k). |
| |
| RESULT: across N = 5..16 and all k, there are 171 solutions, and every one |
| satisfies psi = phi to within +/- 9.6 deg. Ring closure is a LINE, the |
| diagonal, running corner to corner. And nothing populated sits on it: |
| |
| alpha-R 20 deg off beta 110 deg off |
| alpha-L 10 deg off PPII 140 deg off |
| Gly-bridge 90 deg off Pro-II 150 deg off |
| |
| No cyclic peptide can be built from residues all sitting in their preferred |
| geometry. Strain is not a design failure, it is mandatory. |
| |
| CAVEAT: this is derived for a UNIFORM backbone. Real cyclic peptides are not |
| uniform, so the line does not apply to them directly. Testing real dihedrals |
| against it gives 19.6% within +/- 30 deg against a 16.7% uniform null, |
| KS p = 0.075, i.e. nothing. The per-residue generalisation is the 2N-6 |
| dimensional closure manifold, not a line. |
| """ |
| import numpy as np |
| from scipy import optimize |
|
|
| 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)} |
|
|
| def Rx(a): c, s = np.cos(a), np.sin(a); return np.array([[1,0,0],[0,c,-s],[0,s,c]]) |
| def Rz(a): c, s = np.cos(a), np.sin(a); return np.array([[c,-s,0],[s,c,0],[0,0,1]]) |
| def H(R, t): T = np.eye(4); T[:3,:3] = R; T[:3,3] = t; return T |
|
|
| def T_res(phi, psi, omega=np.pi): |
| return (H(Rz(phi), [B['N_CA'],0,0]) @ |
| H(Rz(psi) @ Rx(np.pi - A['N_CA_C']), [B['CA_C'],0,0]) @ |
| H(Rz(omega) @ Rx(np.pi - A['CA_C_N']), [B['C_N'],0,0]) @ |
| H(Rx(np.pi - A['C_N_CA']), [0,0,0])) |
|
|
| def so3_log(R): |
| c = np.clip((np.trace(R)-1)/2, -1, 1); th = np.arccos(c) |
| if th < 1e-9: return np.zeros(3) |
| K = (R - R.T)/(2*np.sin(th)) |
| return th*np.array([K[2,1], K[0,2], K[1,0]]) |
|
|
| def screw(phi, psi): |
| """(rotation per residue, rise per residue) for a uniform backbone.""" |
| T = T_res(phi, psi); w = so3_log(T[:3,:3]); Om = np.linalg.norm(w) |
| if Om < 1e-9: return 0.0, np.linalg.norm(T[:3,3]) |
| ax = w/Om |
| return Om, float(T[:3,3] @ ax) |
|
|
| def resid(x, tgt): |
| Om, ri = screw(x[0], x[1]); return [ri, Om - tgt] |
|
|
| if __name__ == "__main__": |
| rng = np.random.default_rng(1); out = [] |
| print(f" {'N':>4}{'k':>3}{'Omega':>9}{'#sol':>6} representative (phi,psi) deg") |
| for N in range(5, 17): |
| for k in range(1, N//2 + 1): |
| tgt = 2*np.pi*k/N |
| if tgt > np.pi: tgt = 2*np.pi - tgt |
| sols = [] |
| for _ in range(250): |
| x0 = rng.uniform(-np.pi, np.pi, 2) |
| x, info, ier, _ = optimize.fsolve(resid, x0, args=(tgt,), full_output=True) |
| if ier == 1 and max(abs(np.array(resid(x, tgt)))) < 1e-9: |
| x = (x + np.pi) % (2*np.pi) - np.pi |
| if not any(np.allclose(x, y, atol=2e-3) for y in sols): |
| sols.append(x) |
| if sols: |
| out += [(N, k, a, b) for a, b in sols] |
| ex = ", ".join(f"({np.degrees(a):.0f},{np.degrees(b):.0f})" for a, b in sols[:2]) |
| print(f" {N:>4}{k:>3}{np.degrees(tgt):>8.1f}d{len(sols):>6} {ex}") |
| d = np.degrees([((b - a + np.pi) % (2*np.pi)) - np.pi for _, _, a, b in out]) |
| print(f"\n {len(out)} solutions. psi - phi: mean {d.mean():+.3f} deg, " |
| f"sd {d.std():.3f}, range {d.min():+.2f} to {d.max():+.2f}") |
| print(" -> the closure locus is the diagonal psi = phi.") |
|
|