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