melodia-py 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of melodia-py might be problematic. Click here for more details.

melodia_py/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from .melodia_py import geometry_from_structure_file, geometry_dict_from_structure, dataframe_from_alignment
2
+ from .melodia_py import geometry_from_structure, bfactor_from_geo, view_putty, view_cartoon, view_tube
3
+ from .melodia_py import PropensityTable, parser_pir_file, cluster_alignment, save_align_to_ps, save_pymol_script
4
+
5
+
6
+ __version__ = '0.1.0'
7
+ __author__ = 'Rinaldo Wander Montalvão, PhD'
@@ -0,0 +1,582 @@
1
+ # Copyright 2021-2024 KU Leuven.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+ # Author: Rinaldo Wander Montalvão, PhD
16
+ #
17
+ import math
18
+ import warnings
19
+
20
+ import numpy as np
21
+
22
+ from sty import fg
23
+
24
+ from numpy.random import rand, randint
25
+
26
+ from Bio.PDB import PDBParser, PDBIO
27
+ from Bio.SeqUtils import seq1
28
+ from Bio.SVDSuperimposer import SVDSuperimposer
29
+ from Bio.PDB.PDBExceptions import PDBConstructionWarning
30
+
31
+ from sklearn.preprocessing import StandardScaler
32
+ from sklearn.cluster import AgglomerativeClustering
33
+
34
+ warnings.filterwarnings('ignore', category=PDBConstructionWarning)
35
+
36
+
37
+ def rmsd(x: np.ndarray, y: np.ndarray) -> float:
38
+ """
39
+ Compute the RMSD between the two numpy arrays.
40
+ :param x: The first numpy array
41
+ :type x: np.ndarray
42
+ :param y: The second numpy array
43
+ :type y: np.ndarray
44
+ :return: The RMSD between the two numpy arrays
45
+ :rtype: float
46
+ """
47
+
48
+ return np.sqrt((((x - y) ** 2) * 3).mean())
49
+
50
+
51
+ def select(data: np.ndarray, seg: list, msk: list) -> np.ndarray:
52
+ """
53
+ Select the data in the given segment from the mask.
54
+ :param data: The data to select
55
+ :type data: np.ndarray
56
+ :param seg: The segment to select
57
+ :type seg: list
58
+ :param msk: The mask for aligned segment
59
+ :type msk: list
60
+ :return: The selected data
61
+ :rtype: np.ndarray
62
+ """
63
+ tmp = []
64
+ for i, j in enumerate(seg):
65
+ if msk[i]:
66
+ tmp.append(data[j])
67
+
68
+ return np.array(tmp)
69
+
70
+
71
+ def superposition(xo: np.ndarray, yo: np.ndarray, seg: list, msk: list) -> tuple[np.ndarray, np.ndarray]:
72
+ """
73
+ Compute the superposition of the two numpy arrays.
74
+ :param xo: The first numpy array
75
+ :type xo: np.ndarray
76
+ :param yo: The second numpy array
77
+ :type yo: np.ndarray
78
+ :param seg: The segment to superimpose
79
+ :type seg: list
80
+ :param msk: The mask for the aligned segment
81
+ :type msk: list
82
+ :return: The superposition of the two numpy arrays
83
+ :rtype: tuple(np.ndarray, np.ndarray)
84
+ """
85
+ x = select(xo, seg, msk)
86
+ y = select(yo, seg, msk)
87
+
88
+ sup = SVDSuperimposer()
89
+
90
+ sup.set(x, y)
91
+ sup.run()
92
+
93
+ return sup.get_rotran()
94
+
95
+
96
+ def energy(xo: np.ndarray, yo: np.ndarray, seg: list, msk: list) -> float:
97
+ """
98
+ Compute the energy between two numpy arrays.
99
+ :param xo: The first numpy array
100
+ :type xo: np.ndarray
101
+ :param yo: The second numpy array
102
+ :type yo: np.ndarray
103
+ :param seg: The segment to superimpose
104
+ :type seg: list
105
+ :param msk: The mask for the aligned segment
106
+ :type msk: list
107
+ :return: The energy between two numpy arrays
108
+ :rtype: float
109
+ """
110
+ rot, tran = superposition(xo, yo, seg, msk)
111
+
112
+ yt = np.dot(yo, rot) + tran
113
+
114
+ return rmsd(xo, yt)
115
+
116
+
117
+ def segments(anchors: list, members: list) -> list:
118
+ """
119
+ Return a list of segments for each member of the protein alignment of an anchor.
120
+ :param anchors: The list of anchors
121
+ :type anchors: list
122
+ :param members: The list of members
123
+ :type members: list
124
+ :return: The of segments
125
+ :rtype: list
126
+ """
127
+ seg = []
128
+ for member in members:
129
+ ini, end = anchors[member]
130
+ seg.extend([x for x in range(ini, end)])
131
+
132
+ return seg
133
+
134
+
135
+ def simulated_annealing(xo: np.ndarray,
136
+ yo: np.ndarray,
137
+ anchors: list,
138
+ members: list) -> tuple[np.ndarray, np.ndarray, float]:
139
+ """
140
+ Compute the rotation and translation matrices that reduces the RMSD
141
+ between two protein segments.
142
+ :param xo: The first numpy array
143
+ :type xo: np.ndarray
144
+ :param yo: The second numpy array
145
+ :type yo: np.ndarray
146
+ :param anchors: The list of anchors
147
+ :type anchors: list
148
+ :param members: The list of members
149
+ :type members: list
150
+ :return: The rotation and translation matrices and the final RMSD
151
+ :rtype: tuple(np.ndarray, np.ndarray, float)
152
+ """
153
+ seg = segments(anchors, members)
154
+
155
+ msk0 = [True for _ in range(len(seg))]
156
+
157
+ temperature = 300
158
+ energy0 = energy(xo, yo, seg, msk0)
159
+ while temperature > 0.00001:
160
+ for i in range(10000):
161
+ msk1 = msk0.copy()
162
+ j = randint(len(msk1))
163
+ if msk1[j]:
164
+ msk1[j] = False
165
+ else:
166
+ msk1[j] = True
167
+
168
+ energy1 = energy(xo, yo, seg, msk1)
169
+
170
+ energy_delta = energy1 - energy0
171
+
172
+ if energy_delta < 0.0 or rand() < math.exp(-energy_delta / temperature):
173
+ msk0 = msk1.copy()
174
+ energy0 = energy1
175
+
176
+ # TODO: test other schedules
177
+ temperature *= 0.1
178
+
179
+ rot, tran = superposition(xo, yo, seg, msk0)
180
+ return rot, tran, energy0
181
+
182
+
183
+ def superimposer(align, threshold=0.8, csv=False, pdb=True):
184
+ """
185
+ Given an alignment, superimpose the PDB structure using Differential Geometry
186
+ as dissimilarity measure. It can output CSV files with the alpha carbon coordinates
187
+ and cluster groups, and also save the superimposed PDB structures.
188
+ :param align: Alignment to superimpose
189
+ :type align: Bio.Align.MultipleSeqAlignment
190
+ :param threshold: Threshold for cluster selection
191
+ :type threshold: float
192
+ :param csv: If true, output the CSV files
193
+ :type csv: bool
194
+ :param pdb: If true, output the superimposed PDB structures
195
+ :type pdb: bool
196
+ """
197
+ # Collect Differential Geometry data for initial clustering
198
+ data = []
199
+ id2pos = {}
200
+
201
+ for position, record in enumerate(align):
202
+ if 'structure' in record.description:
203
+ id2pos[record.id] = position
204
+ pairs = zip(
205
+ record.letter_annotations['curvature'],
206
+ record.letter_annotations['torsion'],
207
+ )
208
+ data = [list(pair) for pair in pairs]
209
+ record.letter_annotations['cluster'] = [0 for _ in record.seq]
210
+
211
+ scaler = StandardScaler()
212
+ scaler.fit(data)
213
+
214
+ # Clustering to find maximal conserved regions
215
+ clustering = AgglomerativeClustering(distance_threshold=threshold, n_clusters=None)
216
+ for i in range(align.get_alignment_length()):
217
+ xy = []
218
+ tags = []
219
+ for identity, position in id2pos.items():
220
+ record = align[position]
221
+ if record.seq[i] != '-':
222
+ xy.append(
223
+ [
224
+ record.letter_annotations['curvature'][i],
225
+ record.letter_annotations['torsion'][i],
226
+ ]
227
+ )
228
+ tags.append(identity)
229
+
230
+ if len(xy) > 1:
231
+ xy = scaler.transform(xy)
232
+
233
+ clusters = clustering.fit_predict(xy)
234
+
235
+ map_of_clusters = {pair[0]: pair[1] for pair in zip(tags, clusters)}
236
+
237
+ for identity, position in id2pos.items():
238
+ record = align[position]
239
+ if identity in map_of_clusters:
240
+ record.letter_annotations['cluster'][i] = (
241
+ map_of_clusters[identity] + 1
242
+ )
243
+ # print(id, record.letter_annotations['cluster'][i])
244
+ else:
245
+ for identity, position in id2pos.items():
246
+ record = align[position]
247
+ record.letter_annotations['cluster'][i] = 0
248
+
249
+ # Find the highly conserved areas
250
+ ini = 0
251
+ end = align.get_alignment_length()
252
+
253
+ area = []
254
+ for i in range(ini, end):
255
+ s = set()
256
+ for identity, position in id2pos.items():
257
+ record = align[position]
258
+ j = record.letter_annotations['cluster'][i]
259
+ s = s.union({j})
260
+ # Only regions conserved across all proteins
261
+ if len(s) == 1:
262
+ area.append(1)
263
+ else:
264
+ area.append(0)
265
+
266
+ # Find all anchor conserved regions
267
+ anchors = []
268
+ anchor_lengths = []
269
+ new_area = [0 for _ in area]
270
+
271
+ ini, end = 0, 0
272
+ while ini < len(area):
273
+ while ini < len(area):
274
+ if area[ini] == 1:
275
+ break
276
+ else:
277
+ ini += 1
278
+
279
+ end = ini
280
+ while end < len(area):
281
+ if area[end] != 1:
282
+ break
283
+ else:
284
+ end += 1
285
+
286
+ # Minimal length for an anchor regions is 5 residues long
287
+ if end - ini >= 5:
288
+ anchor_lengths.append(end - ini)
289
+ anchors.append((ini, end))
290
+ for i in range(ini, end):
291
+ new_area[i] = 1
292
+ ini = end
293
+
294
+ # Group is the annotation for the new clusters
295
+ for identity, position in id2pos.items():
296
+ record = align[position]
297
+ record.letter_annotations['group'] = [0 for _ in record.seq]
298
+ record.letter_annotations['ca_coords'] = [[0.0, 0.0, 0.0] for _ in record.seq]
299
+
300
+ for anchor in anchors:
301
+ for identity, position in id2pos.items():
302
+ record = align[position]
303
+ ini, end = anchor
304
+ for i in range(ini, end):
305
+ record.letter_annotations['group'][i] = 1
306
+
307
+ # Select the top 5 anchor regions
308
+ top_anchors = np.argsort(anchor_lengths)[::-1]
309
+ if len(top_anchors) > 5:
310
+ top_anchors = top_anchors[:5]
311
+
312
+ # Read the CA coordinates for all the files
313
+ parser = PDBParser()
314
+
315
+ ca_coords = {}
316
+ ca_masked = {}
317
+ structures = {}
318
+ for record in align:
319
+ if 'structure' not in record.description:
320
+ continue
321
+ id = record.id
322
+ structures[id] = parser.get_structure(id, f'{id}.pdb')
323
+
324
+ model = structures[id][0]
325
+ xyz = []
326
+ seq = []
327
+ for chain in model:
328
+ for residue in chain:
329
+ xyz.append(residue['CA'].get_coord())
330
+ seq.append(seq1(residue.get_resname()))
331
+ j = 0
332
+ cds = []
333
+ msk = []
334
+ for i in range(align.get_alignment_length()):
335
+ # Insure coordinate alignment
336
+ if record.seq[i] == '-':
337
+ cds.append(np.array([0.0, 0.0, 0.0]))
338
+ msk.append(False)
339
+ elif record.seq[i] == seq[j]:
340
+ cds.append(xyz[j])
341
+ msk.append(True)
342
+ j += 1
343
+ else:
344
+ print(f'Error: {i} {record.seq[i]} - {j} {seq[j]}')
345
+ ca_coords[id] = np.array(cds)
346
+ ca_masked[id] = np.array(msk)
347
+
348
+ # Optimise the anchor positions for minimal RMSD
349
+ # TODO: Use a dendrogram for the order?
350
+ ids = list(ca_coords.keys())
351
+ ref = ids.pop(0)
352
+ print(f'{ref}')
353
+
354
+ xo = ca_coords[ref]
355
+ for idx in ids:
356
+ yo = ca_coords[idx]
357
+ rot, tran, E0 = simulated_annealing(xo, yo, anchors, top_anchors)
358
+ ca_coords[idx] = np.dot(yo, rot) + tran
359
+ print(f'{idx}: {E0:.2f} Å')
360
+
361
+ for model in structures[idx]:
362
+ for chain in model:
363
+ for residue in chain:
364
+ for atom in residue:
365
+ ya = atom.get_coord()
366
+ atom.set_coord(np.dot(ya, rot) + tran)
367
+
368
+ # Store the CA coordinates in the record
369
+ for idx in ca_coords.keys():
370
+ for i, m in enumerate(ca_masked[idx]):
371
+ if m:
372
+ record = align[id2pos[idx]]
373
+ x = ca_coords[idx][i][0]
374
+ y = ca_coords[idx][i][1]
375
+ z = ca_coords[idx][i][2]
376
+ record.letter_annotations['ca_coords'][i] = [x, y, z]
377
+
378
+ # Remove group outliers
379
+ for anchor in anchors:
380
+ ini, end = anchor
381
+ for i in range(ini, end):
382
+ max_dist = 0.0
383
+ for j in id2pos.keys():
384
+ cj = ca_coords[j][i]
385
+ for k in id2pos.keys():
386
+ if k != j:
387
+ ck = ca_coords[k][i]
388
+ dist = np.linalg.norm(ck - cj)
389
+ if dist > max_dist:
390
+ max_dist = dist
391
+
392
+ # Any region with at least one distance
393
+ # over the threshold is removed
394
+ if max_dist > 2.0:
395
+ for j in id2pos.values():
396
+ record = align[j]
397
+ record.letter_annotations['group'][i] = 0
398
+
399
+ # Remove conserved regions with less than 3 residues
400
+ ini = 0
401
+ end = align.get_alignment_length()
402
+
403
+ area = []
404
+ for i in range(ini, end):
405
+ s = set()
406
+ for position in id2pos.values():
407
+ record = align[position]
408
+ j = record.letter_annotations['group'][i]
409
+ if j == 1:
410
+ s = s.union({j})
411
+ if len(s) == 1:
412
+ area.append(1)
413
+ else:
414
+ area.append(0)
415
+
416
+ regions = []
417
+ regions_lengths = []
418
+ new_area = [0 for _ in area]
419
+
420
+ ini, end = 0, 0
421
+ while ini < len(area):
422
+ while ini < len(area):
423
+ if area[ini] == 1:
424
+ break
425
+ else:
426
+ ini += 1
427
+
428
+ end = ini
429
+ while end < len(area):
430
+ if area[end] != 1:
431
+ break
432
+ else:
433
+ end += 1
434
+ delta = end - ini
435
+ if 0 < delta < 3:
436
+ regions_lengths.append(delta)
437
+ regions.append((ini, end))
438
+ for i in range(ini, end):
439
+ new_area[i] = 1
440
+ ini = end
441
+
442
+ for region in regions:
443
+ ini, end = region
444
+ for i in range(ini, end):
445
+ for position in id2pos.values():
446
+ record = align[position]
447
+ record.letter_annotations['group'][i] = 0
448
+
449
+ # Remove non-conserved regions with less than 3 residues
450
+ ini = 0
451
+ end = align.get_alignment_length()
452
+
453
+ area = []
454
+ for i in range(ini, end):
455
+ s = set()
456
+ for position in id2pos.values():
457
+ record = align[position]
458
+ j = record.letter_annotations['group'][i]
459
+ if j == 0:
460
+ s = s.union({j})
461
+ if len(s) == 1:
462
+ area.append(1)
463
+ else:
464
+ area.append(0)
465
+
466
+ regions = []
467
+ regions_lengths = []
468
+ new_area = [0 for _ in area]
469
+
470
+ ini, end = 0, 0
471
+ while ini < len(area):
472
+ while ini < len(area):
473
+ if area[ini] == 1:
474
+ break
475
+ else:
476
+ ini += 1
477
+
478
+ end = ini
479
+ while end < len(area):
480
+ if area[end] != 1:
481
+ break
482
+ else:
483
+ end += 1
484
+ delta = end - ini
485
+ if 0 < delta < 3:
486
+ regions_lengths.append(delta)
487
+ regions.append((ini, end))
488
+ for i in range(ini, end):
489
+ new_area[i] = 1
490
+ ini = end
491
+
492
+ for region in regions:
493
+ ini, end = region
494
+ for i in range(ini, end):
495
+ for position in id2pos.values():
496
+ record = align[position]
497
+ record.letter_annotations['group'][i] = 1
498
+
499
+ # Output CSV coordinate files
500
+ if csv:
501
+ for idx in ca_coords.keys():
502
+ record = align[id2pos[idx]]
503
+ with open(f'{idx}.csv', 'w') as f:
504
+ for i, m in enumerate(ca_masked[idx]):
505
+ if m:
506
+ x = ca_coords[idx][i][0]
507
+ y = ca_coords[idx][i][1]
508
+ z = ca_coords[idx][i][2]
509
+ g = record.letter_annotations['group'][i]
510
+ f.write(f'{i},{x:.4f},{y:.4f},{z:.4f},{g}\n')
511
+
512
+ # Output superposed PDB files
513
+ if pdb:
514
+ io = PDBIO()
515
+ for idx in ca_coords.keys():
516
+ io.set_structure(structures[idx])
517
+ io.save(f'{idx}_sup.pdb')
518
+
519
+ return
520
+
521
+
522
+ def show_align(align, pal):
523
+ """
524
+ Show the alignment colored by cluster using a Seaborn pallet
525
+ :param align: Alignment to show
526
+ :type align: Bio.Align.MultipleSeqAlignment
527
+ :param pal: Palette
528
+ :type pal: seaborn.palettes._ColorPalette
529
+ """
530
+ length = align.get_alignment_length()
531
+ total = int(length / 50.0)
532
+ for j in range(total + 1):
533
+ ini = 0 + 50 * j
534
+ end = 49 + 50 * j + 1
535
+
536
+ if end >= length:
537
+ end = length - 1
538
+
539
+ if end > 100:
540
+ print(' ', end='')
541
+ for i in range(ini, end):
542
+ s = f'{i:03n}'
543
+ if i % 10 == 0:
544
+ print(s[0], end='')
545
+ else:
546
+ print(' ', end='')
547
+ print()
548
+
549
+ print(' ', end='')
550
+ for i in range(ini, end):
551
+ s = f'{i:03n}'
552
+ if i % 10 == 0:
553
+ print(s[1], end='')
554
+ else:
555
+ print(' ', end='')
556
+ print()
557
+
558
+ print(' ', end='')
559
+ for i in range(ini, end):
560
+ s = f'{i:03n}'
561
+ if i % 10 == 0:
562
+ print(s[2], end='')
563
+ else:
564
+ print(' ', end='')
565
+ print()
566
+
567
+ for record in align:
568
+ if 'structure' not in record.description:
569
+ continue
570
+
571
+ print(record.id, end=':')
572
+ for i in range(ini, end):
573
+ j = record.letter_annotations['group'][i] - 1
574
+ if j >= 0:
575
+ R = int(pal[j][0] * 255)
576
+ G = int(pal[j][1] * 255)
577
+ B = int(pal[j][2] * 255)
578
+ print(fg(R, G, B) + record.seq[i], end='')
579
+ else:
580
+ print(fg(125, 125, 125) + record.seq[i].lower(), end='')
581
+ print(fg.rs)
582
+ return