stcrpy 1.0.0__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.
Files changed (68) hide show
  1. examples/__init__.py +0 -0
  2. examples/egnn.py +425 -0
  3. stcrpy/__init__.py +5 -0
  4. stcrpy/tcr_datasets/__init__.py +0 -0
  5. stcrpy/tcr_datasets/tcr_graph_dataset.py +499 -0
  6. stcrpy/tcr_datasets/tcr_selector.py +0 -0
  7. stcrpy/tcr_datasets/tcr_structure_dataset.py +0 -0
  8. stcrpy/tcr_datasets/utils.py +350 -0
  9. stcrpy/tcr_formats/__init__.py +0 -0
  10. stcrpy/tcr_formats/tcr_formats.py +114 -0
  11. stcrpy/tcr_formats/tcr_haddock.py +556 -0
  12. stcrpy/tcr_geometry/TCRCoM.py +350 -0
  13. stcrpy/tcr_geometry/TCRCoM_LICENCE +168 -0
  14. stcrpy/tcr_geometry/TCRDock.py +261 -0
  15. stcrpy/tcr_geometry/TCRGeom.py +450 -0
  16. stcrpy/tcr_geometry/TCRGeomFiltering.py +273 -0
  17. stcrpy/tcr_geometry/__init__.py +0 -0
  18. stcrpy/tcr_geometry/reference_data/__init__.py +0 -0
  19. stcrpy/tcr_geometry/reference_data/dock_reference_1_imgt_numbered.pdb +6549 -0
  20. stcrpy/tcr_geometry/reference_data/dock_reference_2_imgt_numbered.pdb +6495 -0
  21. stcrpy/tcr_geometry/reference_data/reference_A.pdb +31 -0
  22. stcrpy/tcr_geometry/reference_data/reference_B.pdb +31 -0
  23. stcrpy/tcr_geometry/reference_data/reference_D.pdb +31 -0
  24. stcrpy/tcr_geometry/reference_data/reference_G.pdb +31 -0
  25. stcrpy/tcr_geometry/reference_data/reference_data.py +104 -0
  26. stcrpy/tcr_interactions/PLIPParser.py +147 -0
  27. stcrpy/tcr_interactions/TCRInteractionProfiler.py +433 -0
  28. stcrpy/tcr_interactions/TCRpMHC_PLIP_Model_Parser.py +133 -0
  29. stcrpy/tcr_interactions/__init__.py +0 -0
  30. stcrpy/tcr_interactions/utils.py +170 -0
  31. stcrpy/tcr_methods/__init__.py +0 -0
  32. stcrpy/tcr_methods/tcr_batch_operations.py +223 -0
  33. stcrpy/tcr_methods/tcr_methods.py +150 -0
  34. stcrpy/tcr_methods/tcr_reformatting.py +18 -0
  35. stcrpy/tcr_metrics/__init__.py +2 -0
  36. stcrpy/tcr_metrics/constants.py +39 -0
  37. stcrpy/tcr_metrics/tcr_interface_rmsd.py +237 -0
  38. stcrpy/tcr_metrics/tcr_rmsd.py +179 -0
  39. stcrpy/tcr_ml/__init__.py +0 -0
  40. stcrpy/tcr_ml/geometry_predictor.py +3 -0
  41. stcrpy/tcr_processing/AGchain.py +89 -0
  42. stcrpy/tcr_processing/Chemical_components.py +48915 -0
  43. stcrpy/tcr_processing/Entity.py +301 -0
  44. stcrpy/tcr_processing/Fragment.py +58 -0
  45. stcrpy/tcr_processing/Holder.py +24 -0
  46. stcrpy/tcr_processing/MHC.py +449 -0
  47. stcrpy/tcr_processing/MHCchain.py +149 -0
  48. stcrpy/tcr_processing/Model.py +37 -0
  49. stcrpy/tcr_processing/Select.py +145 -0
  50. stcrpy/tcr_processing/TCR.py +532 -0
  51. stcrpy/tcr_processing/TCRIO.py +47 -0
  52. stcrpy/tcr_processing/TCRParser.py +1230 -0
  53. stcrpy/tcr_processing/TCRStructure.py +148 -0
  54. stcrpy/tcr_processing/TCRchain.py +160 -0
  55. stcrpy/tcr_processing/__init__.py +3 -0
  56. stcrpy/tcr_processing/annotate.py +480 -0
  57. stcrpy/tcr_processing/utils/__init__.py +0 -0
  58. stcrpy/tcr_processing/utils/common.py +67 -0
  59. stcrpy/tcr_processing/utils/constants.py +367 -0
  60. stcrpy/tcr_processing/utils/region_definitions.py +782 -0
  61. stcrpy/utils/__init__.py +0 -0
  62. stcrpy/utils/error_stream.py +12 -0
  63. stcrpy-1.0.0.dist-info/METADATA +173 -0
  64. stcrpy-1.0.0.dist-info/RECORD +68 -0
  65. stcrpy-1.0.0.dist-info/WHEEL +5 -0
  66. stcrpy-1.0.0.dist-info/licenses/LICENCE +28 -0
  67. stcrpy-1.0.0.dist-info/licenses/stcrpy/tcr_geometry/TCRCoM_LICENCE +168 -0
  68. stcrpy-1.0.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,450 @@
1
+ import warnings
2
+ import numpy as np
3
+
4
+ from ..tcr_processing import abTCR, MHCchain
5
+ from .TCRCoM import MHCI_TCRCoM, MHCII_TCRCoM
6
+
7
+
8
+ class TCRGeom:
9
+ """Class for TCR geometry calculations."""
10
+
11
+ def __init__(
12
+ self,
13
+ tcr: "TCR",
14
+ save_aligned_as: bool = False,
15
+ polarity_as_sign: bool = True,
16
+ mode: str = "cys",
17
+ ):
18
+ """Constructor for TCR geometry calculation class.
19
+
20
+ Args:
21
+ tcr (TCR): TCR structure object of which to calculate complex geometry. TCR must be in complex to MHC.
22
+ save_aligned_as (bool, optional): Save the alignment of the TCR:pMHC complex as PDB file for verification. Defaults to False.
23
+ polarity_as_sign (bool, optional): Use the sign of the angle to indicate non-canonical pose. ie reverse binding angles are assigned negative sign. Defaults to True.
24
+ mode (str, optional): Method to calculate geometry with, see STCRpy paper for details. Defaults to "cys". Options: 'rudolph', 'com', 'cys'.
25
+ """
26
+ self.tcr_com = None
27
+ self.mhc_com = None
28
+ self.tcr_VA_com = None
29
+ self.tcr_VB_com = None
30
+ self.tcr_VA_cys_centroid = None
31
+ self.tcr_VB_cys_centroid = None
32
+ self.tcr_VA_VB_angle = None
33
+ self.scanning_angle = np.nan
34
+ self.tcr_pitch_angle = np.nan
35
+ self.tcr_docking_angles_cys = None
36
+ self.tcr_mhc_dist = None
37
+ self.mode = mode
38
+
39
+ self._type_checks(tcr)
40
+
41
+ mhc = tcr.get_MHC()[0]
42
+
43
+ self._set_mhc_reference(mhc)
44
+
45
+ if self.mode != "rudolph":
46
+ (self.tcr_com, self.mhc_com, self.tcr_VA_com, self.tcr_VB_com) = (
47
+ self.mhc_tcr_com_calculator.calculate_centres_of_mass(
48
+ tcr, save_aligned_as=save_aligned_as
49
+ )
50
+ )
51
+
52
+ if self.mode != "com":
53
+ (self.tcr_VA_cys_centroid, self.tcr_VB_cys_centroid) = (
54
+ self._get_cys_centroids(tcr)
55
+ )
56
+
57
+ if self.mode in ["cys", "rudolph"]:
58
+ self.tcr_vector = self.get_tcr_vector(
59
+ self.tcr_VA_cys_centroid, self.tcr_VB_cys_centroid
60
+ )
61
+ elif self.mode == "com":
62
+ self.tcr_vector = self.get_tcr_vector(self.tcr_VA_com, self.tcr_VB_com)
63
+ else:
64
+ warnings.warn(
65
+ f"Geometry mode {self.mode} not recognised. Using CYS atom coordinates to calculate geometry"
66
+ )
67
+ self.tcr_vector = self.get_tcr_vector(
68
+ self.tcr_VA_cys_centroid, self.tcr_VB_cys_centroid
69
+ )
70
+
71
+ if self.mode == "rudolph":
72
+ self.mhc_vector = self._get_mhc_helix_vectors(mhc)
73
+ self.scanning_angle = self.calculate_rudolph_angle(
74
+ self.tcr_vector, self.mhc_vector
75
+ )
76
+
77
+ else:
78
+ self.scanning_angle, self.tcr_pitch_angle = (
79
+ self.calculate_tcr_docking_angles(
80
+ self.tcr_vector, polarity_as_sign=polarity_as_sign
81
+ )
82
+ )
83
+
84
+ self.polarity = self.get_polarity()
85
+
86
+ def __repr__(self) -> str:
87
+ """Print TCR:pMHC complex geometry.
88
+
89
+ Returns:
90
+ str: TCR:pMHC complex geometry metrics.
91
+ """
92
+ def polarity_to_str(polarity):
93
+ return "canonical" if polarity == 0 else "reverse"
94
+
95
+ def mode_to_str(mode):
96
+ if mode == "cys":
97
+ return "Cysteine centroids"
98
+ elif mode == "com":
99
+ return "Centre of mass"
100
+ elif mode == "rudolph":
101
+ return "Rudolph et. al. 2006"
102
+ else:
103
+ return mode
104
+
105
+ return (
106
+ f"TCR CoM: {self.tcr_com}\nMHC CoM: {self.mhc_com}\n"
107
+ + f"TCR VA CoM: {self.tcr_VA_com}\nTCR VB CoM: {self.tcr_VB_com}\n"
108
+ + f"TCR VA CYS Centroid: {self.tcr_VA_cys_centroid}\nTCR VB CYS Centroid: {self.tcr_VB_cys_centroid}\n"
109
+ + f"Scanning angle: {np.degrees(self.scanning_angle)}\n"
110
+ + f"Pitch angle: {np.degrees(self.tcr_pitch_angle)}\n"
111
+ + f"Polarity: {polarity_to_str(self.polarity)}\n"
112
+ + f"Geometry mode: {mode_to_str(self.mode)}"
113
+ )
114
+
115
+ def to_dict(self) -> dict:
116
+ """Return TCR:pMHC complex geometry metrics as dictionary.
117
+
118
+ Returns:
119
+ dict: TCR:pMHC complex geometry.
120
+ """
121
+ return {
122
+ "tcr_com": [self.tcr_com],
123
+ "mhc_com": [self.mhc_com],
124
+ "tcr_VA_com": [self.tcr_VA_com],
125
+ "tcr_VB_com": [self.tcr_VB_com],
126
+ "tcr_VA_cys_centroid": [self.tcr_VA_cys_centroid],
127
+ "tcr_VB_cys_centroid": [self.tcr_VB_cys_centroid],
128
+ "scanning_angle": np.degrees(self.scanning_angle),
129
+ "pitch_angle": np.degrees(self.tcr_pitch_angle),
130
+ "polarity": self.polarity,
131
+ "mode": self.mode,
132
+ }
133
+
134
+ def to_df(self) -> "pandas.DataFrame":
135
+ """Return TCR:pMHC complex geometry metrics and pandas dataframe.
136
+
137
+ Returns:
138
+ pandas.DataFrame: TCR:pMHC complex geometry metrics
139
+ """
140
+ import pandas as pd
141
+
142
+ return pd.DataFrame.from_dict(self.to_dict())
143
+
144
+ def get_scanning_angle(self, rad: bool = False) -> float:
145
+ """Return TCR:pMHC complex scanning (aka crossing, incident angle) of TCR to MHC.
146
+
147
+ Args:
148
+ rad (bool, optional): Return angle in radians. Defaults to False.
149
+
150
+ Returns:
151
+ float: TCR:pMHC scanning angle.
152
+ """
153
+ if rad:
154
+ return self.scanning_angle
155
+ else:
156
+ return np.degrees(self.scanning_angle)
157
+
158
+ def get_pitch_angle(self, rad: bool = False) -> float:
159
+ """Return TCR:pMHC pitch angle, ie tilt of the TCR over the MHC.
160
+
161
+ Args:
162
+ rad (bool, optional): Return angle in radians. Defaults to False.
163
+
164
+ Returns:
165
+ float: TCR:pMHC pitch angle
166
+ """
167
+ if rad:
168
+ return self.tcr_pitch_angle
169
+ else:
170
+ return np.degrees(self.tcr_pitch_angle)
171
+
172
+ def _type_checks(self, tcr: "TCR"):
173
+ """Run checks on TCR structure object to ensure geometry can be calculated.
174
+ \nChecks if TCR is in complex with MHC and whether TCR is alpha/beta chain TCR.
175
+
176
+ Args:
177
+ tcr (TCR): TCR structure object.
178
+
179
+ Raises:
180
+ NotImplementedError: TCR:pMHC geometry calculations are compatible the alpha beta chain TCRs.
181
+ """
182
+ if len(tcr.get_MHC()) == 0:
183
+ warnings.warn(
184
+ f"No MHC associated with TCR {tcr.parent.parent.id}_{tcr.id}. Geometry cannot be calculated."
185
+ )
186
+ return
187
+
188
+ if not isinstance(tcr, abTCR):
189
+ raise NotImplementedError(
190
+ f"TCR MHC geometry only implemented for abTCR types, not {type(tcr)}"
191
+ )
192
+
193
+ def _set_mhc_reference(self, mhc: "MHC"):
194
+ """Sets the MHC structure reference to use for alignment.
195
+
196
+ Args:
197
+ mhc (MHC): MHC structure object
198
+
199
+ Raises:
200
+ NotImplementedError: MHC alignments are compatible with MHC class I and II. CD1 and MR1 types currently not supported.
201
+ ValueError: MHC unrecognised.
202
+ """
203
+ if (isinstance(mhc, MHCchain) and mhc.chain_type not in ["GA", "GB"]) or (
204
+ hasattr(mhc, "MHC_type") and mhc.MHC_type == "MH1"
205
+ ):
206
+ self.mhc_tcr_com_calculator = MHCI_TCRCoM()
207
+ elif (isinstance(mhc, MHCchain) and mhc.chain_type in ["GA", "GB"]) or (
208
+ hasattr(mhc, "MHC_type") and mhc.MHC_type == "MH2"
209
+ ):
210
+ self.mhc_tcr_com_calculator = MHCII_TCRCoM()
211
+ else:
212
+ if hasattr(mhc, "MHC_type") and mhc.get_MHC_type() in ["CD1", "MR1"]:
213
+ raise NotImplementedError(
214
+ "TCR geometry not yet implemented for CD1 and MR1 antigen."
215
+ )
216
+ else:
217
+ raise ValueError(f"MHC type of {mhc} not recognised.")
218
+
219
+ def get_tcr_vector(self, tcr_VA_com: np.array, tcr_VB_com: np.array) -> np.array:
220
+ """Calculates the vector from the VA centre of mass to the VB centre of mass, translated such that the vector originates at the total TCR's centre of mass and truncated to unit length.
221
+ Args:
222
+ tcr_VA_com (np.array): TCR VA centre of mass
223
+ tcr_VB_com (np.array): TCR VB center of mass
224
+
225
+ Returns:
226
+ np.array: Unit vector from TCR CoM in direction of VA CoM to VB CoM
227
+ """
228
+ direction_vec = tcr_VB_com - tcr_VA_com
229
+ tcr_vector = direction_vec / np.linalg.norm(direction_vec)
230
+ return tcr_vector
231
+
232
+ def calculate_tcr_docking_angles(
233
+ self, tcr_vector: np.array, polarity_as_sign: bool = True
234
+ ) -> tuple[np.array]:
235
+ """
236
+ Calculates the scanning angle and pitch of the TCR relative to the MHC.
237
+ This function relies on the previous alignment of the MHC to the reference MHC,
238
+ with the x axis defined perpedicular to the peptide, the y-axis along the peptide,
239
+ and the z-axis pointing 'up' away from the MHC.
240
+
241
+ The scanning angle is calculated as the angle between the y axis
242
+ and the projection of the TCR vector, which points from VA to VB, onto the x-y plane.
243
+
244
+ The pitch is calculated as the angle between the z-axis and the plane normal
245
+ to the TCR vector and passing through the TCR CoM. Specifically the angle of the vector from the
246
+ z-axis intersection and the nearest projection of the z-axis onto the plane dividing the VA and VB
247
+ TCR domains is calculated, which is defined as the pitch of the TCR.
248
+
249
+ Args:
250
+ tcr_vector (np.array): Unit vector in direction of VA CoM to VB CoM
251
+ polarity (bool, optional): Set the polarity as the sign of the scanning angle
252
+ ie negative if polarity is reversed (1),
253
+ else positive if polarity is canonical (0). Defaults to True.
254
+ Returns:
255
+ tuple[np.array]: Tuple containing the scanning angle and pitch angle of the TCR to the MHC
256
+ """
257
+
258
+ xy_projection = tcr_vector[:2] / np.linalg.norm(tcr_vector[:2])
259
+ self.scanning_angle = np.arccos(np.dot(xy_projection, np.asarray([0.0, 1.0])))
260
+ phi = np.arccos(np.sqrt(1 - (tcr_vector[-1] ** 2)))
261
+ if polarity_as_sign:
262
+ self.scanning_angle = self.scanning_angle * ((-1) ** self.get_polarity())
263
+ return self.scanning_angle, phi
264
+
265
+ def get_polarity(self) -> int:
266
+ """
267
+ Return the polarity of the TCR based on the TCR vector pointing from the VA to the VB CoM, or the scanning angle if using Rudolph et. al..
268
+ If the x component is negative, ie the tcr_vector points from the alpha 2 chain to the
269
+ alpha 1 chain of the MHC, the polarity is canonical (0). Otherwise the polarity is reverse (1).
270
+
271
+ Returns:
272
+ int: 0 for canonical polarity, 1 for reverse polarity.
273
+ """
274
+ if self.mode in ["cys", "com"]:
275
+ if self.tcr_vector[0] <= 0:
276
+ return 0
277
+ else:
278
+ return 1
279
+ elif self.mode == "rudolph":
280
+ return 0 if np.abs(np.degrees(self.scanning_angle)) < 120.0 else 1
281
+
282
+ def _get_cys_centroids(self, tcr: "TCR") -> tuple[np.array, np.array]:
283
+ """Calculate the halfway coordintate bewtween the CYS residues of the V.
284
+
285
+ Args:
286
+ tcr (TCR): TCR structure object
287
+
288
+ Raises:
289
+ KeyError: Cys residue not found in V domain
290
+ KeyError: SG and CA atoms not found in Cys residue
291
+
292
+ Returns:
293
+ tuple[np.array, np.array]: (Cys centroid of VA/VG, Cys centroid of VB/VD)
294
+ """
295
+ domain_order = {
296
+ "VA": 0,
297
+ "VB": 3,
298
+ "VG": 1,
299
+ "VD": 2,
300
+ } # defines order s.t. order is VA->VB, VG->VD, VD->VB depending on pairing
301
+ cys_coords = {dom: {} for dom in tcr.get_domain_assignment()}
302
+ for domain, chain in tcr.get_domain_assignment().items():
303
+ for cys_res_nr in [23, 104]:
304
+ try:
305
+ cys_coords[domain][cys_res_nr] = tcr[chain][cys_res_nr]["SG"].coord
306
+ except KeyError as e:
307
+ if str(cys_res_nr) in str(e):
308
+ raise KeyError(
309
+ f"IMGT numbered residue {str(cys_res_nr)} not found in {tcr.id} domain {domain} with chain ID {chain}. Consider calculating TCR geometry with centre of mass coordinates. "
310
+ )
311
+ elif "SG" in str(e):
312
+ warnings.warn(
313
+ f"SG atom not found in IMGT residue number {str(cys_res_nr)} of {tcr.id} domain {domain} with chain ID {chain}. Trying CA atom instead."
314
+ )
315
+ try:
316
+ cys_coords[domain][cys_res_nr] = tcr[chain][cys_res_nr][
317
+ "CA"
318
+ ]
319
+ except KeyError:
320
+ raise KeyError(
321
+ f"Neither SG not CA atom found in IMGT residue number {str(cys_res_nr)} of {tcr.id} domain {domain} with chain ID {chain}. Consider calculating TCR geometry with centre of mass coordinates."
322
+ )
323
+
324
+ cys_centroids = (
325
+ np.mean([cys_coords[dom][res_nr] for res_nr in [23, 104]], axis=0)
326
+ for dom in sorted(
327
+ cys_coords, key=lambda x: domain_order[x]
328
+ ) # calculate cys centroids and orders such that vector can be calculated from VA to VB (VG to VD)
329
+ )
330
+
331
+ return cys_centroids # VA CYS centroid, VB CYS centroid
332
+
333
+ def _get_mhc_helix_vectors(self, mhc: "MHC") -> np.array:
334
+ """Calculate the vector pointing along the MHC using the MHC helices. Points approximately from N to C terminus of GA chain.
335
+ \nVector is calculated as principal component of SVD decomposition of point cloud of backbone carbon alpha atoms in the helices forming the peptide cleft.
336
+
337
+ Args:
338
+ mhc (MHC): MHC structure object
339
+
340
+ Returns:
341
+ np.array: Unit vector along the MHC
342
+ """
343
+ helix_residue_ranges = {
344
+ "MH1": ((50, 87), (140, 177)),
345
+ "MH2": {"GA": ((50, 88),), "GB": ((54, 65), (67, 91))},
346
+ # "CD1": (50, 87), # TODO: determine consisent residue ranges for CD1 and MR1 helices
347
+ # "MR1": (50, 87),
348
+ }
349
+
350
+ if mhc.get_MHC_type() == "MH1":
351
+ helix_CA_coords = np.asarray(
352
+ [
353
+ mhc.get_MH1()[(" ", i, " ")]["CA"].coord
354
+ for _range in helix_residue_ranges[mhc.get_MHC_type()]
355
+ for i in range(*_range)
356
+ if (" ", i, " ") in mhc.get_MH1()
357
+ and "CA" in mhc.get_MH1()[(" ", i, " ")]
358
+ ]
359
+ )
360
+ # mhc vector should point approximately from N to C terminii of GA1 (MH1)
361
+ try: # wrap in try except in case residues at edges don't exist or atoms are missing
362
+ approximate_mhc_vector = (
363
+ mhc.get_MH1()[(" ", helix_residue_ranges["MH1"][0][1] - 1, " ")][
364
+ "CA"
365
+ ].coord
366
+ - mhc.get_MH1()[(" ", helix_residue_ranges["MH1"][0][0], " ")][
367
+ "CA"
368
+ ].coord
369
+ )
370
+ except KeyError: # try surrounding residues
371
+ approximate_mhc_vector = (
372
+ mhc.get_MH1()[(" ", helix_residue_ranges["MH1"][0][1] - 2, " ")][
373
+ "CA"
374
+ ].coord
375
+ - mhc.get_MH1()[(" ", helix_residue_ranges["MH1"][0][0] + 1, " ")][
376
+ "CA"
377
+ ].coord
378
+ )
379
+ approximate_mhc_vector = approximate_mhc_vector / np.linalg.norm(
380
+ approximate_mhc_vector
381
+ )
382
+ elif mhc.get_MHC_type() == "MH2":
383
+ helix_CA_coords = np.asarray(
384
+ [
385
+ mhc.get_GA()[(" ", i, " ")]["CA"].coord
386
+ for _range in helix_residue_ranges[mhc.get_MHC_type()]["GA"]
387
+ for i in range(*_range)
388
+ if (" ", i, " ") in mhc.get_GA()
389
+ and "CA" in mhc.get_GA()[(" ", i, " ")]
390
+ ]
391
+ + [
392
+ mhc.get_GB()[(" ", i, " ")]["CA"].coord
393
+ for _range in helix_residue_ranges[mhc.get_MHC_type()]["GB"]
394
+ for i in range(*_range)
395
+ if (" ", i, " ") in mhc.get_GB()
396
+ and "CA" in mhc.get_GB()[(" ", i, " ")]
397
+ ]
398
+ )
399
+ # mhc vector should point approximately from N to C terminii of GA (MH2)
400
+ try: # wrap in try except in case residues at edges don't exist or atoms are missing
401
+ approximate_mhc_vector = (
402
+ mhc.get_GA()[
403
+ (" ", helix_residue_ranges["MH2"]["GA"][0][1] - 1, " ")
404
+ ]["CA"].coord
405
+ - mhc.get_GA()[(" ", helix_residue_ranges["MH2"]["GA"][0][0], " ")][
406
+ "CA"
407
+ ].coord
408
+ )
409
+ except KeyError: # try surrounding residues
410
+ approximate_mhc_vector = (
411
+ mhc.get_GB()[
412
+ (" ", helix_residue_ranges["MH2"]["GA"][0][1] - 2, " ")
413
+ ]["CA"].coord
414
+ - mhc.get_GB()[
415
+ (" ", helix_residue_ranges["MH2"]["GA"][0][0] + 1, " ")
416
+ ]["CA"].coord
417
+ )
418
+ approximate_mhc_vector = approximate_mhc_vector / np.linalg.norm(
419
+ approximate_mhc_vector
420
+ )
421
+
422
+ mhc_helix_points = helix_CA_coords - np.mean(
423
+ helix_CA_coords, axis=0
424
+ ) # centre helices
425
+ mhc_vector = np.linalg.svd(mhc_helix_points)[2][0] # fit line
426
+ # check if approximate vector and MHC vector are aligned by checking magnitude of vector sum, if not, invert MHC vector
427
+ if np.linalg.norm(mhc_vector + approximate_mhc_vector) < np.linalg.norm(
428
+ mhc_vector - approximate_mhc_vector
429
+ ):
430
+ mhc_vector = -mhc_vector
431
+ mhc_vector = mhc_vector / np.linalg.norm(mhc_vector)
432
+ return mhc_vector
433
+
434
+ def calculate_rudolph_angle(
435
+ self, tcr_vector: np.array, mhc_vector: np.array
436
+ ) -> float:
437
+ """Calculate the scanning angle of TCR:pMHC complex according to Rudolph et. al.
438
+
439
+ Args:
440
+ tcr_vector (np.array): Vector pointing from VA Cys centroid to VB Cys centroid
441
+ mhc_vector (np.array): Vector pointing along MHC cleft
442
+
443
+ Returns:
444
+ float: scanning angle
445
+ """
446
+ scanning_angle = np.arccos(
447
+ np.dot(tcr_vector, mhc_vector)
448
+ / (np.linalg.norm(tcr_vector) * np.linalg.norm(mhc_vector))
449
+ )
450
+ return scanning_angle