"""
Created on 3rd April 2024
Nele Quast based on work by Dunbar and Leem
The TCR class.
"""
import sys
import warnings
from .Entity import Entity
try:
from .. import tcr_interactions
except ImportError as e:
warnings.warn(
"TCR interaction profiling could not be imported. Check PLIP installation"
)
print(e)
[docs]
class TCR(Entity):
"""
TCR class. Inherits from PDB.Entity.
This is a base class for TCR strucutres, enabling antigen and MHC association.
abTCR and gdTCR are the instantiated subclasses of this class.
"""
def _add_antigen(self, antigen=None):
"""
Append associated antigen to TCR antigen field.
Args:
antigen (Antigen, optional): Antigen to associate with TCR. Defaults to None.
"""
if antigen not in self.antigen:
self.antigen.append(antigen)
def _add_mhc(self, mhc=None):
"""
Append associated MHC to TCR MHC field. If antigen are associted with MHC but not TCR, add them to TCR antigen.
Args:
mhc (MHC, optional): MHC to associate with TCR. Defaults to None.
"""
if mhc not in self.MHC:
self.MHC.append(mhc)
# If there are any het antigens that are in the MHC but not in close proximity of the TCR
# (e.g. 4x6c antigen) then add it to the TCR.
if set(mhc.antigen) - set(self.antigen):
self.antigen.extend(mhc.antigen)
[docs]
def get_antigen(self):
"""
Return a list of TCR associated antigens.
"""
return self.antigen
[docs]
def get_MHC(self):
"""
Return a list of TCR associated MHCs.
"""
return self.MHC
[docs]
def is_bound(self):
"""True or False if the TCR is associated with an antigen.
Returns:
bool: Whether TCR is associated with an antigen.
"""
if self.get_antigen():
return True
else:
return False
[docs]
def get_chains(self):
"""Returns generator of TCR chains.
Yields:
Chain: TCR chain
"""
for c in self:
yield c
[docs]
def get_residues(self):
"""Returns generator of TCR residues.
Yields:
Residue: TCR residue
"""
for c in self.get_chains():
for r in c:
yield r
[docs]
def get_atoms(self):
"""Returns generator of TCR atoms.
Yields:
Atom: TCR atoms
"""
for r in self.get_residues():
for a in r:
yield a
[docs]
def get_frameworks(self):
"""
Obtain framework regions from a TCR structure object as generator.
Yields:
Fragment: TCR framework regions
"""
for f in self.get_fragments():
if "fw" in f.id:
yield f
[docs]
def get_CDRs(self):
"""
Obtain complementarity determining regions (CDRs) from a TCR structure object as generator.
Yields:
Fragment: TCR CDR regions
"""
for f in self.get_fragments():
if "cdr" in f.id:
yield f
[docs]
def get_TCR_type(self):
"""Get TCR type according to variable region assignments.
Returns:
str: TCR type (abTCR, gdTCR, dbTCR)
"""
if hasattr(self, "tcr_type"):
return self.tcr_type
elif hasattr(self, "VB") and hasattr(self, "VA"):
self.tcr_type = "abTCR"
return self.tcr_type
elif hasattr(self, "VD") and hasattr(self, "VG"):
self.tcr_type = "gdTCR"
return self.tcr_type
elif hasattr(self, "VB") and hasattr(self, "VD"):
self.tcr_type = "dbTCR"
return self.tcr_type
[docs]
def get_germline_assignments(self):
"""Retrive germline assignments for all TCR chains.
This is a dictionary with the chain ID as key and the germline assignments as value.
Returns:
dict: dict with TCR chain ID as key and germline assignments as value
"""
return {c.id: c.get_germline_assignments() for c in self.get_chains()}
[docs]
def get_MHC_allele_assignments(self):
"""
Retrieve MHC allele assignments for all TCR associated MHCs.
This is a list of dictionaries with the MHC ID as key and the allele assignments as value.
Returns:
dict: dict with MHC chain ID as key and allele assignments as value
"""
return [
(
mhc.get_allele_assignments()
if mhc.level
!= "C" # results in identical nesting structure for MHC and MHCchain types
else {mhc.id: mhc.get_allele_assignments()}
)
for mhc in self.get_MHC()
]
[docs]
def get_germlines_and_alleles(self):
"""Get all germline and allele assignments for TCR and MHC chains as a dictionary with the chain ID as key and the germline assignments as value.
Returns:
dict: Dictionary of TCR germline and MHC allele assignemnts with amino acid sequences.
"""
from ..tcr_formats.tcr_formats import get_sequences
germlines_and_alleles = {}
try:
germlines = self.get_germline_assignments()
for tcr_domain, c in self.get_domain_assignment().items():
germlines_and_alleles[tcr_domain] = (
germlines[c]["v_gene"][0][1],
germlines[c]["j_gene"][0][1],
)
germlines_and_alleles[f"{tcr_domain}_species"] = sorted(
tuple(
set(
(
germlines[c]["v_gene"][0][0],
germlines[c]["j_gene"][0][0],
)
)
)
)
germlines_and_alleles[f"TCR_{tcr_domain}_seq"] = get_sequences(self[c])[
c
]
if len(self.get_MHC()) == 1:
MHC = self.get_MHC()[0]
alleles = self.get_MHC_allele_assignments()[0]
germlines_and_alleles["MHC_type"] = (
MHC.get_MHC_type() if MHC.level != "C" else MHC.chain_type
)
MHC_domains = {list(d.keys())[0]: c for c, d in alleles.items()}
for d, c in MHC_domains.items():
germlines_and_alleles[f"MHC_{d}"] = alleles[c][d][0][1]
germlines_and_alleles[f"MHC_{d}_seq"] = (
get_sequences(MHC[c])[c]
if MHC.level != "C"
else get_sequences(MHC)[c]
)
germlines_and_alleles["antigen"] = (
get_sequences(self.get_antigen()[0])[self.get_antigen()[0].id]
if len(self.get_antigen()) == 1
else None
)
except Exception as e:
warnings.warn(
f"Germline and allele retrieval failed for {self} with error {str(e)}"
)
return germlines_and_alleles
[docs]
def save(self, save_as=None, tcr_only: bool = False, format: str = "pdb"):
"""Save TCR object as PDB or MMCIF file.
Args:
save_as (str, optional): File path to save TCR to. Defaults to None.
tcr_only (bool, optional): Whether to save TCR only or to include MHC and antigen. Defaults to False.
format (str, optional): Whether to save as PDB or MMCIF. Defaults to "pdb".
"""
from . import TCRIO
tcrio = TCRIO.TCRIO()
tcrio.save(self, save_as=save_as, tcr_only=tcr_only, format=format)
[docs]
def get_scanning_angle(self, mode="rudolph"):
"""
Returns TCR:pMHC complex scanning (aka crossing, incident) angle of TCR to MHC.
See paper for details.
Args:
mode (str, optional): Mode for calculating the scanning angle. Options "rudolph", "cys", "com". Defaults to "rudolph".
Returns:
float: Scanning angle of TCR to MHC in degrees
"""
if not hasattr(self, "geometry") or self.geometry.mode != mode:
self.calculate_docking_geometry(mode=mode)
return self.geometry.get_scanning_angle()
[docs]
def get_pitch_angle(self, mode="cys"):
"""
Returns TCR:pMHC complex pitch angle of TCR to MHC.
See paper for details.
Args:
mode (str, optional): Mode for calculating the scanning angle. Options "rudolph", "cys", "com". Defaults to "cys".
Returns:
float: Pitch angle of TCR to MHC in degrees
"""
if not hasattr(self, "geometry") or self.geometry.mode != mode:
self.calculate_docking_geometry(mode=mode)
return self.geometry.get_pitch_angle()
[docs]
def calculate_docking_geometry(self, mode="rudolph", as_df=False):
"""Calculate docking geometry of TCR to MHC.
This is a wrapper function for the TCRGeom class.
Args:
mode (str, optional): Mode for calculating the geometry. Options "rudolph", "cys", "com". Defaults to "rudolph".
as_df (bool, optional): Whether to return as dictionary or dataframe. Defaults to False.
Returns:
[dict, DataFrame]: TCR to MHC geometry.
"""
if len(self.get_MHC()) == 0:
warnings.warn(
f"No MHC found for TCR {self}. Docking geometry cannot be calcuated"
)
return None
try: # import here to avoid circular imports
from ..tcr_geometry.TCRGeom import TCRGeom
except ImportError as e:
warnings.warn(
"TCR geometry calculation could not be imported. Check installation"
)
raise ImportError(str(e))
self.geometry = TCRGeom(self, mode=mode)
if as_df:
return self.geometry.to_df()
return self.geometry.to_dict()
[docs]
def score_docking_geometry(self, **kwargs):
"""
Score docking geometry of TCR to MHC.
This is a wrapper function for the TCRGeomFiltering class.
The score is calculated as the negative log of the TCR:pMHC complex geometry feature probabilities based on the distributions fit by maximum likelihood estimation of TCR to Class I MHC strucutres from STCRDab.
Please see the paper methods for details.
Returns:
float: TCR:pMHC complex score as negative log of TCR:pMHC complex geometry feature probabilities
"""
from ..tcr_geometry.TCRGeomFiltering import DockingGeometryFilter
geom_filter = DockingGeometryFilter()
if not hasattr(self, "geometry"):
self.calculate_docking_geometry(mode="com")
return geom_filter.score_docking_geometry(
self.geometry.get_scanning_angle(mode="com"),
self.geometry.get_pitch_angle(mode="com"),
self.geometry.tcr_com[-1], # z component of TCR centre of mass
)
[docs]
def profile_peptide_interactions(
self, renumber: bool = True, save_to: str = None, **kwargs
) -> "pd.DataFrame":
"""
Profile the interactions of the peptide to the TCR and the MHC.
Args:
renumber (bool, optional): Whether to renumber the interacting residues. Defaults to True.
save_to (str, optional): Path to save intraction data to as csv. Defaults to None.
Returns:
pd.DataFrame: Dataframe of peptide interactions
"""
if len(self.get_antigen()) == 0:
warnings.warn(
f"No peptide antigen found for TCR {self}. Peptide interactions cannot be profiled"
)
return None
if "PLIPParser" not in [m.split(".")[-1] for m in sys.modules]:
warnings.warn(
"TCR interactions module was not imported. Check warning log and PLIP installation"
)
return None
from ..tcr_interactions import TCRInteractionProfiler
interaction_profiler = TCRInteractionProfiler.TCRInteractionProfiler(**kwargs)
interactions = interaction_profiler.get_interactions(
self, renumber=renumber, save_as_csv=save_to
)
return interactions
[docs]
def get_interaction_heatmap(self, plotting_kwargs={}, **interaction_kwargs):
"""
Get interaction heatmap of TCR to MHC and peptide.
Generates heatmap image.
Plotting kwargs are passed to heatmap function.
Args:
plotting_kwargs (dict, optional):
save_as: path to save heatmap image to
interaction_type: type of interaction (eg. saltbridge, h_bond) to plot. All interactions are plotted by default.
antigen_name: name of antigen for plot title
mutation_index: index of antigen residues to highlight in plot
Defaults to {
save_as:None,
interaction_type:None,
antigen_name:None,
mutation_index:None
}.
interaction_kwargs: kwargs for TCRInteractionProfiler class. See TCRInteractionProfiler for details.
"""
from ..tcr_interactions import TCRInteractionProfiler
interaction_profiler = TCRInteractionProfiler.TCRInteractionProfiler(
**interaction_kwargs
)
interaction_profiler.get_interaction_heatmap(self, **plotting_kwargs)
[docs]
def profile_TCR_interactions(self):
raise NotImplementedError
[docs]
def profile_MHC_interactions(self):
raise NotImplementedError
def _create_interaction_visualiser(self):
"""Function called during TCR initialisation checks if pymol is installed and assigns a visualisation method accordingly.
If pymol is installed, method to generate interaction visualisations is returned.
If pymol is not installed, calling the visualisation
Returns:
callable: TCR bound method to visualise interactions of the TCR and MHC to the peptide.
"""
try:
import pymol
def visualise_interactions(
save_as=None, antigen_residues_to_highlight=None, **interaction_kwargs
):
"""Visualise peptide interactions in pymol.
Args:
save_as (str, optional): path to save pymol session to. Defaults to None.
antigen_residues_to_highlight (list[int], optional): antigen residues to highlight red in pymol session. Defaults to None.
**interaction_kwargs: kwargs for TCRInteractionProfiler class. See TCRInteractionProfiler for details.
Returns:
str: path to saved pymol session
"""
from ..tcr_interactions import TCRInteractionProfiler
interaction_profiler = TCRInteractionProfiler.TCRInteractionProfiler(
**interaction_kwargs
)
interaction_session_file = interaction_profiler.create_pymol_session(
self,
save_as=save_as,
antigen_residues_to_highlight=antigen_residues_to_highlight,
)
return interaction_session_file
return visualise_interactions
except ModuleNotFoundError:
def visualise_interactions(**interaction_kwargs):
warnings.warn(
f"""pymol was not imported. Interactions were not visualised.
\nTo enable pymol visualisations please install pymol in a conda environment with:
\nconda install -c conda-forge -c schrodinger numpy pymol-bundle\n\n
"""
)
return visualise_interactions
except ImportError as e:
def visualise_interactions(import_error=e, **interaction_kwargs):
warnings.warn(
f"""pymol was not imported. Interactions were not visualised. This is due to an import error. Perhaps try reinstalling pymol?
\nThe error trace was: {str(import_error)}
"""
)
return visualise_interactions
[docs]
class abTCR(TCR):
"""
abTCR class. Inherits from TCR.
This is a subclass of TCR for TCRs with alpha and beta chains.
"""
def __init__(self, c1, c2):
"""
Initialise abTCR object. This is a subclass of TCR for TCRs with alpha and beta chains.
Args:
c1 (TCRchain): alpha or beta type TCR chain
c2 (TCRchain): alpha or beta type TCR chain
"""
if c1.chain_type == "B":
Entity.__init__(self, c1.id + c2.id)
else:
Entity.__init__(self, c2.id + c1.id)
# The TCR is a Holder class
self.level = "H"
self._add_domain(c1)
self._add_domain(c2)
self.child_list = sorted(
self.child_list, key=lambda x: x.chain_type, reverse=True
) # make sure that the list goes B->A or G->D
self.antigen = []
self.MHC = []
self.engineered = False
self.scTCR = False # This is rare but does happen
self.visualise_interactions = self._create_interaction_visualiser()
def __repr__(self):
"""
String representation of the abTCR object.
Returns:
str: String representation of the abTCR objec
"""
return "<TCR %s%s beta=%s; alpha=%s>" % (self.VB, self.VA, self.VB, self.VA)
def _add_domain(self, chain):
"""
Add a variable alpha or variable beta domain to the TCR object.
Links the domain to the chain ID.
Args:
chain (TCRchain): TCR chain whose domain is being added.
"""
if chain.chain_type == "B":
self.VB = chain.id
elif chain.chain_type == "A" or chain.chain_type == "D":
self.VA = chain.id
# Add the chain as a child of this entity.
self.add(chain)
[docs]
def get_VB(self):
"""
Retrieve the variable beta chain of the TCR
Returns:
TCRchain: VB chain
"""
if hasattr(self, "VB"):
return self.child_dict[self.VB]
[docs]
def get_VA(self):
"""
Retrieve the variable alpha chain of the TCR
Returns:
TCRchain: VA chain
"""
if hasattr(self, "VA"):
return self.child_dict[self.VA]
[docs]
def get_domain_assignment(self):
"""
Retrieve the domain assignment of the TCR as a dict with variable domain type as key and chain ID as value.
Returns:
dict: domain assignment from domain to chain ID, e.g. {"VA": "D", "VB": "E"}
"""
try:
return {"VA": self.VA, "VB": self.VB}
except AttributeError:
if hasattr(self, "VB"):
return {"VB": self.VB}
if hasattr(self, "VA"):
return {"VA": self.VA}
return None
[docs]
def is_engineered(self):
"""
Flag for engineered TCRs.
Returns:
bool: Flag for engineered TCRs
"""
if self.engineered:
return True
else:
vb, va = self.get_VB(), self.get_VA()
for var_domain in [vb, va]:
if var_domain and var_domain.is_engineered():
self.engineered = True
return self.engineered
self.engineered = False
return False
[docs]
def get_fragments(self):
"""
Retrieve the fragments, ie FW and CDR loops of the TCR as a generator.
Yields:
Fragment: fragment of TCR chain.
"""
vb, va = self.get_VB(), self.get_VA()
# If a variable domain exists
for var_domain in [vb, va]:
if var_domain:
for frag in var_domain.get_fragments():
yield frag
[docs]
class gdTCR(TCR):
def __init__(self, c1, c2):
if c1.chain_type == "D":
Entity.__init__(self, c1.id + c2.id)
else:
Entity.__init__(self, c2.id + c1.id)
# The TCR is a Holder class
self.level = "H"
self._add_domain(c1)
self._add_domain(c2)
self.child_list = sorted(
self.child_list, key=lambda x: x.chain_type
) # make sure that the list goes B->A or D->G
self.antigen = []
self.MHC = []
self.engineered = False
self.scTCR = False # This is rare but does happen
self.visualise_interactions = self._create_interaction_visualiser()
def __repr__(self):
return "<TCR %s%s delta=%s; gamma=%s>" % (self.VD, self.VG, self.VD, self.VG)
def _add_domain(self, chain):
if chain.chain_type == "D":
self.VD = chain.id
elif chain.chain_type == "G":
self.VG = chain.id
# Add the chain as a child of this entity.
self.add(chain)
[docs]
def get_VD(self):
if hasattr(self, "VD"):
return self.child_dict[self.VD]
[docs]
def get_VG(self):
if hasattr(self, "VG"):
return self.child_dict[self.VG]
[docs]
def get_domain_assignment(self):
try:
return {"VG": self.VG, "VD": self.VD}
except AttributeError:
if hasattr(self, "VD"):
return {"VD": self.VD}
if hasattr(self, "VG"):
return {"VG": self.VG}
return None
[docs]
def is_engineered(self):
if self.engineered:
return True
else:
vd, vg = self.get_VD(), self.get_VG()
for var_domain in [vd, vg]:
if var_domain and var_domain.is_engineered():
self.engineered = True
return self.engineered
self.engineered = False
return False
[docs]
def get_fragments(self):
vd, vg = self.get_VD(), self.get_VG()
# If a variable domain exists
for var_domain in [vg, vd]:
if var_domain:
for frag in var_domain.get_fragments():
yield frag
[docs]
class dbTCR(TCR):
def __init__(self, c1, c2):
super(TCR, self).__init__()
if c1.chain_type == "B":
Entity.__init__(self, c1.id + c2.id)
else:
Entity.__init__(self, c2.id + c1.id)
# The TCR is a Holder class
self.level = "H"
self._add_domain(c1)
self._add_domain(c2)
self.child_list = sorted(
self.child_list, key=lambda x: x.chain_type, reverse=False
) # make sure that the list goes B->D
self.antigen = []
self.MHC = []
self.engineered = False
self.scTCR = False # This is rare but does happen
self.visualise_interactions = self._create_interaction_visualiser()
def __repr__(self):
return "<TCR %s%s beta=%s; delta=%s>" % (self.VB, self.VD, self.VB, self.VD)
def _add_domain(self, chain):
if chain.chain_type == "B":
self.VB = chain.id
elif chain.chain_type == "D":
self.VD = chain.id
# Add the chain as a child of this entity.
self.add(chain)
[docs]
def get_VB(self):
if hasattr(self, "VB"):
return self.child_dict[self.VB]
[docs]
def get_VD(self):
if hasattr(self, "VD"):
return self.child_dict[self.VD]
[docs]
def get_domain_assignment(self):
try:
return {"VD": self.VD, "VB": self.VB}
except AttributeError:
if hasattr(self, "VB"):
return {"VB": self.VB}
if hasattr(self, "VD"):
return {"VD": self.VD}
return None
[docs]
def is_engineered(self):
if self.engineered:
return True
else:
vb, vd = self.get_VB(), self.get_VD()
for var_domain in [vb, vd]:
if var_domain and var_domain.is_engineered():
self.engineered = True
return self.engineered
self.engineered = False
return False
[docs]
def get_fragments(self):
vb, vd = self.get_VB(), self.get_VD()
# If a variable domain exists
for var_domain in [vb, vd]:
if var_domain:
for frag in var_domain.get_fragments():
yield frag