#!/usr/bin/env python """ Conformational analysis of free-PROTAC MD replicas. Reads all rep*/ trajectories under a solvent directory and computes, per replica and pooled: radius of gyration (Rg), RMSD to the first frame (convergence), solvent-accessible 3D polar surface area (3D PSA) and intramolecular H-bond count, then PCA + K-means clustering into conformational states with populations and saved centroid structures. The dashboard PNG carries the literature yardsticks (7 A Rg permeability cutoff, 3D PSA ~200-210 A^2). Time axes assume 10 ps per frame, protac_md.py's default --report-ps; a run made with a different --report-ps gets ns labels scaled by that ratio. Usage: python analyze_conformations.py --dir egfrvhl_rank4/chloroform --name egfrvhl_rank4 """ import argparse, glob, os import numpy as np import mdtraj as md import matplotlib; matplotlib.use("Agg") import matplotlib.pyplot as plt from scipy.cluster.vq import kmeans2 RG_CUT = 0.70 # nm, passive-permeability cutoff (Atilaw 2021) PSA_LO, PSA_HI = 200.0, 210.0 # A^2; low-moderate permeability band COLORS = ["#0072B2", "#D55E00", "#009E73", "#CC79A7", "#E69F00"] INK, MUTED, GRID = "#222", "#666", "#DDD" def ligand_indices(top): return [at.index for res in top.residues if not res.is_water and res.n_atoms > 5 for at in res.atoms] def polar_slice_indices(top_lig): """Indices (within the ligand-only topology) of polar atoms: N, O, and H on N/O.""" nbr = {} for b in top_lig.bonds: nbr.setdefault(b[0].index, []).append(b[1].index) nbr.setdefault(b[1].index, []).append(b[0].index) polar = [] for at in top_lig.atoms: s = at.element.symbol if s in ("N", "O"): polar.append(at.index) elif s == "H" and any(top_lig.atom(j).element.symbol in ("N", "O") for j in nbr.get(at.index, [])): polar.append(at.index) return np.array(sorted(set(polar))) def load_replicas(solvent_dir, name): top_pdbs = glob.glob(os.path.join(solvent_dir, f"{name}_*_solvated.pdb")) if not top_pdbs: raise SystemExit(f"no *_solvated.pdb in {solvent_dir}") top = top_pdbs[0] reps = [] for rd in sorted(glob.glob(os.path.join(solvent_dir, "rep*"))): dcd = os.path.join(rd, f"{name}_prod.dcd") if os.path.exists(dcd): reps.append((os.path.basename(rd), md.load(dcd, top=top))) if not reps: raise SystemExit(f"no rep*/{name}_prod.dcd trajectories in {solvent_dir}") return reps def per_replica_metrics(traj): top = traj.topology lig = ligand_indices(top) ligtraj = traj.atom_slice(lig) ligtraj.center_coordinates() rg = md.compute_rg(ligtraj) # nm rmsd = md.rmsd(ligtraj, ligtraj, 0) * 10.0 # A, vs frame 0 sasa = md.shrake_rupley(ligtraj, mode="atom") # nm^2 per atom polar = polar_slice_indices(ligtraj.topology) psa = sasa[:, polar].sum(axis=1) * 100.0 # A^2 hb = [len(x) for x in md.wernet_nilsson(ligtraj)] # intramolecular H-bonds/frame return dict(rg=rg, rmsd=rmsd, psa=psa, hb=np.array(hb, float), ligtraj=ligtraj) def cluster(ligtrajs, k=5, seed=0): pooled = md.join(ligtrajs) pooled.superpose(pooled, 0) X = pooled.xyz.reshape(pooled.n_frames, -1) X = X - X.mean(0) # PCA via SVD, keeping components up to 90% variance (cap 10) U, S, Vt = np.linalg.svd(X, full_matrices=False) var = (S ** 2) / (S ** 2).sum() ncomp = min(10, max(2, int(np.searchsorted(np.cumsum(var), 0.90) + 1))) proj = U[:, :ncomp] * S[:ncomp] cent, labels = kmeans2(proj, k, minit="++", seed=seed) pops = np.array([(labels == i).mean() for i in range(k)]) # global frame index closest to each cluster centroid, in PC space cframes = [] for i in range(k): idx = np.where(labels == i)[0] cframes.append(int(idx[np.argmin(((proj[idx] - cent[i]) ** 2).sum(1))]) if len(idx) else -1) return pooled, labels, pops, cframes, proj def style(ax, title, xlabel, ylabel): ax.set_title(title, fontsize=11, color=INK, loc="left", weight="bold", pad=6) ax.set_xlabel(xlabel, fontsize=9, color=MUTED); ax.set_ylabel(ylabel, fontsize=9, color=MUTED) ax.grid(True, color=GRID, lw=0.6, alpha=0.8); ax.set_axisbelow(True) for s in ("top", "right"): ax.spines[s].set_visible(False) for s in ("left", "bottom"): ax.spines[s].set_color(GRID) ax.tick_params(colors=MUTED, labelsize=8) def main(): ap = argparse.ArgumentParser() ap.add_argument("--dir", required=True, help="solvent dir containing rep*/") ap.add_argument("--name", required=True) ap.add_argument("--clusters", type=int, default=5) a = ap.parse_args() reps = load_replicas(a.dir, a.name) solvent = os.path.basename(a.dir.rstrip("/")) print(f"[analyze] {a.name} / {solvent}: {len(reps)} replicas") M = {rn: per_replica_metrics(tr) for rn, tr in reps} ligtrajs = [M[rn]["ligtraj"] for rn, _ in reps] pooled, labels, pops, cframes, proj = cluster(ligtrajs, a.clusters) rg_all = np.concatenate([M[rn]["rg"] for rn, _ in reps]) psa_all = np.concatenate([M[rn]["psa"] for rn, _ in reps]) hb_all = np.concatenate([M[rn]["hb"] for rn, _ in reps]) rep_means = [M[rn]["rg"].mean() for rn, _ in reps] # ---- dashboard ---- fig, axes = plt.subplots(2, 3, figsize=(15, 8.5), dpi=140) fig.patch.set_facecolor("white") ax = axes[0, 0] for i, (rn, _) in enumerate(reps): t = np.arange(len(M[rn]["rg"])) * 0.01 # ns; assumes protac_md.py default --report-ps 10 ax.plot(t, M[rn]["rg"], color=COLORS[i % 5], lw=0.8, alpha=0.85, label=rn) ax.axhline(RG_CUT, color="k", ls="--", lw=1); ax.text(0.99, RG_CUT+0.003, "7 A permeability cutoff", transform=ax.get_yaxis_transform(), ha="right", fontsize=7, color=MUTED) style(ax, "Radius of gyration", "time (ns)", "Rg (nm)"); ax.legend(fontsize=7, frameon=False) ax = axes[0, 1] for i, (rn, _) in enumerate(reps): t = np.arange(len(M[rn]["rmsd"])) * 0.01 ax.plot(t, M[rn]["rmsd"], color=COLORS[i % 5], lw=0.8, alpha=0.85) style(ax, "RMSD to start (convergence)", "time (ns)", "RMSD (A)") ax = axes[0, 2] ax.hexbin(rg_all, psa_all, gridsize=35, cmap="Blues", mincnt=1) ax.axvline(RG_CUT, color="k", ls="--", lw=1) ax.axhspan(PSA_LO, PSA_HI, color="orange", alpha=0.15) ax.text(0.02, PSA_HI, " PSA ~200-210 A^2", transform=ax.get_yaxis_transform(), fontsize=7, color=MUTED, va="bottom") style(ax, "3D PSA vs Rg (folding->permeability)", "Rg (nm)", "3D PSA (A^2)") ax = axes[1, 0] order = np.argsort(-pops) ax.bar(range(len(pops)), pops[order] * 100, color=[COLORS[i % 5] for i in range(len(pops))]) for r, i in enumerate(order): idx = labels == i if idx.any(): ax.text(r, pops[i]*100 + 0.5, f"Rg {md.compute_rg(pooled[idx]).mean():.2f}", ha="center", fontsize=7, color=MUTED) style(ax, f"Conformational clusters (k={a.clusters})", "cluster (by population)", "population (%)") ax = axes[1, 1] ax.hist(hb_all, bins=np.arange(0, hb_all.max()+2)-0.5, color="#0072B2", alpha=0.85, rwidth=0.9) style(ax, "Intramolecular H-bonds", "H-bonds per frame", "count") ax = axes[1, 2]; ax.axis("off") summary = [ f"PROTAC : {a.name}", f"Solvent: {solvent} Replicas: {len(reps)}", f"Frames : {pooled.n_frames} pooled", "", f"Rg : {rg_all.mean():.3f} ± {rg_all.std():.3f} nm", f" per-rep means: " + ", ".join(f"{x:.3f}" for x in rep_means), f" replica spread (std of means): {np.std(rep_means):.3f} nm", f" frac below 7 A cutoff: {100*np.mean(rg_all= 0: pooled[cframes[i]].save_pdb(os.path.join(a.dir, f"{a.name}_cluster{r+1}_pop{pops[i]*100:.0f}.pdb")) print(f"[analyze] Rg {rg_all.mean():.3f} nm | 3D PSA {psa_all.mean():.0f} A^2 | " f"IMHB {hb_all.mean():.1f} | replica-mean spread {np.std(rep_means):.3f} nm") if __name__ == "__main__": main()