Skip to content

Commit 52d802e

Browse files
common plotting util that reports can leverage
1 parent 447519b commit 52d802e

1 file changed

Lines changed: 324 additions & 0 deletions

File tree

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
from collections import OrderedDict
2+
import plotly.graph_objects as go
3+
from Bio import PDB
4+
import matplotlib.pyplot as plt
5+
import numpy as np
6+
import os
7+
8+
def reset_residue_numbers(structure):
9+
"""
10+
Resets residue numbering in a PDB file, because ESMFold starts
11+
and increment only when encountering a new residue.
12+
"""
13+
if str(structure).endswith(".pdb"):
14+
parser = PDB.PDBParser(QUIET=True)
15+
elif str(structure).endswith(".cif"):
16+
parser = PDB.MMCIFParser(QUIET=True)
17+
else:
18+
print(f"{structure} is neither a PDB or mmCIF file!")
19+
return
20+
21+
structure = parser.get_structure("structure", structure)
22+
23+
for model in structure:
24+
for idx, residue in enumerate(model.get_residues(), start=1):
25+
# Do a swap in place to renumber the residue, the other entries in the tuple can stay the same
26+
# See: https://biopython.org/docs/1.76/api/Bio.PDB.Chain.html#Bio.PDB.Chain.Chain.__getitem__
27+
het_atom, _, insertion_code = residue.get_id()
28+
residue.id = (het_atom, idx, insertion_code)
29+
30+
io = PDB.PDBIO()
31+
io.set_structure(structure)
32+
33+
return structure
34+
35+
# TODO: Barcelona team to implement AF3
36+
def sort_structures_by_rank(structures, prog):
37+
"""
38+
Sorts a list of structures based on their rank. Needs to handle different program naming
39+
"""
40+
if prog == "alphafold2":
41+
# AlphaFold2 structures are named with [run]/ranked_[rank].pdb
42+
sorted_structures = sorted(structures, key=lambda x: int(os.path.basename(x).replace('ranked_', '').split('.')[0]))
43+
if prog == "colabfold":
44+
# ColabFold structures are named with [run]_unrelaxed_rank_[rank]_alphafold2_ptm_model_[num]_seed_[seed].pdb
45+
sorted_structures = sorted(structures, key=lambda x: int(os.path.basename(x).split('_')[3]))
46+
if prog == "helixfold3":
47+
# HelixFold3 structures are named with .../[run]/[run]-rank[rank]/predicted_structure.pdb
48+
sorted_structures = sorted(structures, key=lambda x: int(os.path.dirname(x).split('rank')[-1]))
49+
if prog == "esmfold" or "rosettafold-all-atom":
50+
# ESMFold and RoseTTAFold only produce one structure
51+
sorted_structures = structures[0]
52+
if prog == "boltz1":
53+
# Boltz1 structures are named with ..._model_[diffusion_samples-1].[pdb|cif]
54+
sorted_structures = sorted(structures, key=lambda x: int(os.path.basename(x).split('_model_')[-1]))
55+
else:
56+
print(f"Warning: Sorting not implemented for {prog}. Using original order.")
57+
return structures
58+
59+
return sorted_structures
60+
61+
def align_structures(structures):
62+
63+
if not structures:
64+
raise ValueError("No structures provided for alignment.")
65+
66+
if structures[0].endswith(".pdb"):
67+
parser = PDB.PDBParser(QUIET=True)
68+
elif structures[0].endswith(".cif"):
69+
parser = PDB.MMCIFParser(QUIET=True)
70+
else:
71+
raise ValueError(f"{structure} is neither a PDB or mmCIF file!")
72+
73+
parsed_structures = [parser.get_structure(f"structure-{idx}", structure) for idx, structure in enumerate(structures)]
74+
ref_structure = parsed_structures[0]
75+
76+
def get_atom_ids(structure):
77+
# Note: this is a *set* of atom_ids due to the {} surrounding the comprehension
78+
return {(atom.get_parent().get_id(), atom.name) for atom in structure.get_atoms()}
79+
80+
# TODO: do we want to raise and error if the structures are not identical atomically, or keep the ability to sub-align?
81+
# Update the atoms shared between structures with progressive intersections
82+
common_atoms = get_atom_ids(ref_structure)
83+
for structure in parsed_structures[1:]:
84+
common_atoms.intersection_update(get_atom_ids(structure))
85+
86+
if not common_atoms:
87+
raise ValueError("No common atoms found between structures.")
88+
89+
def extract_atoms(structure, atom_ids):
90+
# Note: this comprehension returns an atom *object* for each atom in the structure
91+
return {atom for atom in structure.get_atoms() if (atom.get_parent().get_id(), atom.name) in atom_ids}
92+
93+
ref_atoms = extract_atoms(ref_structure, common_atoms)
94+
95+
# The aligned structures will be the parsed structures aligned to the common atoms of the reference structure
96+
super_imposer = PDB.Superimposer()
97+
aligned_structures = []
98+
for idx, structure in enumerate(parsed_structures):
99+
# The reference structure doesn't need to be aligned so can be skipped
100+
if idx == 0:
101+
aligned_structures.append(structure)
102+
continue
103+
104+
target_atoms = extract_atoms(structure, common_atoms)
105+
super_imposer.set_atoms(ref_atoms, target_atoms)
106+
super_imposer.apply(structure.get_atoms())
107+
108+
io = PDB.PDBIO()
109+
io.set_structure(structure)
110+
aligned_structures.append(structure)
111+
112+
# Technically, parsed_structures now also points to the same aligned structures, but I've kept for readability
113+
return aligned_structures
114+
115+
def plddt_from_struct_b_factor(structure):
116+
"""
117+
Uses the BioPython PDB package to extract residue pLDDT values from the b-factor column. Iterates over PDB objects rather than processes raw file
118+
"""
119+
if str(structure).endswith(".pdb"):
120+
parser = PDB.PDBParser(QUIET=True)
121+
structure = parser.get_structure(id=id, file=structure)
122+
elif str(structure).endswith(".cif"):
123+
parser = PDB.MMCIFParser(QUIET=True)
124+
structure = parser.get_structure(structure_id=id, filename=structure)
125+
else:
126+
print(f"{structure} is neither a PDB or mmCIF file!")
127+
128+
res_list = []
129+
res_plddts = []
130+
plddt_tot = 0
131+
132+
for model in structure:
133+
for chain in model:
134+
chain_res_list = chain.get_unpacked_list()
135+
res_list.extend(chain_res_list)
136+
for residue in chain:
137+
atom_list = residue.get_unpacked_list()
138+
atom_plddt_tot = 0
139+
for atom in residue: # ESMFold and others have separate atom-wise values, so doing atom-wise to cover that and residue-wise
140+
atom_plddt = atom.get_bfactor()
141+
atom_plddt_tot += atom_plddt
142+
143+
res_plddt = float(atom_plddt_tot / len(atom_list))
144+
145+
if (res_plddt < 1): # RFAA the multiplication of mean isn't failing. Anyway covering to a [0,100] range for any structure file1
146+
res_plddt *= 100
147+
148+
res_plddts.append(res_plddt)
149+
plddt_tot += res_plddt
150+
151+
res_plddts = np.array(res_plddts)
152+
res_plddts = np.round(res_plddts, 2)
153+
154+
return res_plddts
155+
156+
def generate_plddt_plot(structures):
157+
"""
158+
Generate a Plotly figure for predicted LDDT per position for given structures.
159+
160+
Args:
161+
structures (list): List of structure file paths.
162+
163+
Returns:
164+
go.Figure: Plotly figure object with pLDDT data.
165+
"""
166+
plddt_per_struct = OrderedDict()
167+
168+
for struct in structures:
169+
plddt_per_struct[struct] = plddt_from_struct_b_factor(struct)
170+
171+
fig = go.Figure()
172+
173+
for idx, (struct, plddts) in enumerate(plddt_per_struct.items()):
174+
fig.add_trace(
175+
go.Scatter(
176+
x=list(range(len(plddts))),
177+
y=plddts,
178+
mode="lines",
179+
name=f"rank-{idx}",
180+
text=[f"({idx}, {value:.2f})" for idx, value in enumerate(plddts)],
181+
hoverinfo="text",
182+
)
183+
)
184+
fig.update_layout(
185+
title=dict(text="Predicted LDDT per position", x=0.5, xanchor="center"),
186+
xaxis=dict(
187+
title="Positions", showline=True, linecolor="black", gridcolor="WhiteSmoke"
188+
),
189+
yaxis=dict(
190+
title="Predicted LDDT",
191+
range=[0, 100],
192+
showline=True,
193+
linecolor="black",
194+
gridcolor="WhiteSmoke",
195+
),
196+
legend=dict(
197+
yanchor="bottom", y=0.02, xanchor="right", x=1, bordercolor="Black", borderwidth=1
198+
),
199+
plot_bgcolor="white",
200+
width=600,
201+
height=600,
202+
)
203+
204+
return fig
205+
206+
def process_msas(msa_path):
207+
msa = np.loadtxt(msa_path, dtype=int)
208+
209+
query_sequence = msa[0]
210+
seqid_match = np.mean(msa == query_sequence, axis=1)
211+
212+
# Sort sequences by sequence identity
213+
seqid_sort_indices = np.argsort(seqid_match)
214+
sorted_msa = msa[seqid_sort_indices]
215+
sorted_seqid = seqid_match[seqid_sort_indices]
216+
217+
non_gaps_msas = np.where(sorted_msa != 21, 1.0, np.nan)
218+
219+
# Scale non-gap positions by sequence identity
220+
final_msas = non_gaps_msas * sorted_seqid[:, None]
221+
222+
return final_msas, non_gaps_msas
223+
224+
def generate_sequence_coverage_plot(msa_path, out_dir, name, save_image=True):
225+
final_msas, non_gaps_msas = process_msas(msa_path)
226+
#
227+
seq_depth_counts = np.sum(~np.isnan(non_gaps_msas), axis=0)
228+
229+
# TODO: don't have a seperate save image plot and an HTML plotly ploy
230+
# ##################################################################
231+
# Plot the sequence coverage with matplotlib and save as image
232+
# ##################################################################
233+
if save_image:
234+
image_path = f"{out_dir}/{name+('_' if name else '')}seq_coverage.png"
235+
plt.figure(figsize=(14, 14), dpi=100)
236+
plt.title("Sequence coverage", fontsize=30, pad=36)
237+
plt.imshow(
238+
final_msas,
239+
interpolation="nearest",
240+
aspect="auto",
241+
cmap="rainbow_r",
242+
vmin=0,
243+
vmax=1,
244+
origin="lower",
245+
)
246+
247+
248+
plt.plot(seq_depth_counts, color="black")
249+
plt.xlim(-0.5, len(final_msas[0]) - 0.5)
250+
plt.ylim(-0.5, len(final_msas) - 0.5)
251+
252+
plt.tick_params(axis="both", which="both", labelsize=18)
253+
254+
cbar = plt.colorbar()
255+
cbar.set_label("Sequence identity to query", fontsize=24, labelpad=24)
256+
cbar.ax.tick_params(labelsize=18)
257+
plt.xlabel("Positions", fontsize=24, labelpad=24)
258+
plt.ylabel("Sequences", fontsize=24, labelpad=36)
259+
plt.savefig(image_path)
260+
261+
# ##################################################################
262+
# Interactive HTML plot of sequence coverage
263+
fig = go.Figure()
264+
fig.add_trace(
265+
go.Heatmap(
266+
z=final_msas,
267+
colorscale="Rainbow_r",
268+
zmin=0,
269+
zmax=1,
270+
colorbar={"title": 'Your title'}
271+
)
272+
)
273+
# Add black line for sequence coverage depth
274+
fig.add_trace(
275+
go.Scatter(
276+
x=list(range(len(seq_depth_counts))),
277+
y=seq_depth_counts,
278+
mode="lines",
279+
line=dict(color="black", width=2),
280+
name="Coverage Depth",
281+
)
282+
)
283+
fig.update_layout(
284+
title=dict(text="Sequence coverage", x=0.5, xanchor="center"),
285+
xaxis_title="Positions", yaxis_title="Sequences",
286+
)
287+
288+
if save_image:
289+
return fig, image_path
290+
else:
291+
return fig
292+
293+
def generate_pae_plot(pae_path, out_dir, name, save_image=True):
294+
"""
295+
Generate a Plotly heatmap for Predicted Aligned Error (PAE) data.
296+
297+
Args:
298+
pae (2D array): The PAE matrix.
299+
Returns:
300+
fig: A Plotly figure object of the PAE heatmap in green color scale
301+
"""
302+
pae = np.genfromtxt(pae_path, delimiter="\t")
303+
max_pae = np.max(pae)
304+
fig = go.Figure()
305+
306+
# Add heatmap
307+
fig.add_trace(
308+
go.Heatmap(
309+
z=pae,
310+
colorscale="Greens_r",
311+
zmin=0,
312+
zmax=max_pae,
313+
)
314+
)
315+
fig.update_layout(
316+
xaxis=dict(title="Scored Residue"),
317+
yaxis=dict(title="Aligned Residue"),
318+
)
319+
320+
if save_image:
321+
image_path = f"{out_dir}/{name+('_' if name else '')}pae.png"
322+
fig.write_image(image_path, width=800, height=800)
323+
324+
return fig

0 commit comments

Comments
 (0)