"""Core Pydantic models representing a kinase at the kinase-domain level.
Defines :class:`KinaseInfo` and its component models (:class:`UniProt`,
:class:`KinHub`, :class:`KLIFS`, :class:`Pfam`, :class:`KinCore`) along with the
:class:`Group`/:class:`Family` enumerations. :class:`KinaseInfo` exposes adjudication
helpers for kinase-domain sequence and group assignment.
"""
import logging
from enum import Enum
from mkt.schema.constants import LIST_FULL_KLIFS_REGION, LIST_KLIFS_REGION, LIST_PFAM_KD
from mkt.schema.utils import rgetattr
from pydantic import BaseModel, ConfigDict, Field, constr, field_validator
from strenum import StrEnum
logger = logging.getLogger(__name__)
[docs]
class Group(StrEnum):
"""Enum class for kinase groups."""
AGC = "AGC" # Protein Kinase A, G, and C families
Atypical = "Atypical" # Atypical protein kinases
CAMK = "CAMK" # Calcium/calmodulin-dependent protein kinase family
CK1 = "CK1" # Casein kinase 1 family
CMGC = "CMGC" # Cyclin-dependent kinase, Mitogen-activated protein kinase, Glycogen synthase kinase, and CDK-like kinase families
NEK = "NEK" # NIMA (Never in Mitosis Gene A)-related kinase family - KinCore treats as group
RGC = "RGC" # Receptor guanylate cyclase family
STE = "STE" # Homologs of yeast Sterile 7, Sterile 11, Sterile 20 kinases
TK = "TK" # Tyrosine kinase family
TKL = "TKL" # Tyrosine kinase-like family
Other = "Other" # Other protein kinases
[docs]
class Family(Enum):
"""Enum class for kinase families (>=5 in KinHub)."""
STE20 = "STE20"
CAMKL = "CAMKL"
CDK = "CDK"
Eph = "Eph"
PIK = "PIK"
MAPK = "MAPK"
STKR = "STKR"
NEK = "NEK"
Src = "Src"
DYRK = "DYRK"
PKC = "PKC"
STE11 = "STE11"
RSK = "RSK"
MLK = "MLK"
GRK = "GRK"
CK1 = "CK1"
DMPK = "DMPK"
STE7 = "STE7"
PIKK = "PIKK"
RSKb = "RSKb"
Alpha = "Alpha"
Tec = "Tec"
CAMK1 = "CAMK1"
PDGFR = "PDGFR"
ULK = "ULK"
DAPK = "DAPK"
RAF = "RAF"
RIPK = "RIPK"
MLCK = "MLCK"
PKA = "PKA"
MAPKAPK = "MAPKAPK"
RGC = "RGC"
CDKL = "CDKL"
MAST = "MAST"
TSSK = "TSSK"
ABC1 = "ABC1"
PDHK = "PDHK"
JakA = ("Jak", "JakA")
JakB = ("Jakb", "JakB")
PIPK = "PIPK"
PLK = "PLK"
Other = "Other"
Null = None
KinaseDomainName = StrEnum(
"KinaseDomainName", {"KD" + str(idx + 1): kd for idx, kd in enumerate(LIST_PFAM_KD)}
)
SeqUniProt = constr(pattern=r"^[ACDEFGHIKLMNPQRSTVWXY]+$")
"""Pydantic model for UniProt sequence constraints."""
SeqKLIFS = constr(pattern=r"^[ACDEFGHIKLMNPQRSTVWY\-]{85}$")
"""Pydantic model for KLIFS pocket sequence constraints."""
SwissProtPattern = r"^[A-Z][0-9][A-Z0-9]{3}[0-9](_[12])?$"
"""Regex pattern for SwissProt ID."""
SwissProtID = constr(pattern=SwissProtPattern)
"""Pydantic model for SwissProt ID constraints."""
TrEMBLPattern = r"^[A-Z][0-9][A-Z][A-Z0-9]{2}[0-9][A-Z][A-Z0-9]{2}[0-9](_[12])?$"
"""Regex pattern for TrEBML ID."""
TrEMBLID = constr(pattern=TrEMBLPattern)
"""Pydantic model for TrEMBL ID constraints."""
SwissProtPatternSuffix = SwissProtPattern.replace("$", "") + r"(_[12])?$"
"""Regex pattern for SwissProt ID with optional '_1' or '_2' for multi-KD."""
SwissProtIDSuffix = constr(pattern=SwissProtPatternSuffix)
"""Pydantic model for SwissProt ID constraints with suffix."""
TrEMBLPatternSuffix = TrEMBLPattern.replace("$", "") + r"(_[12])?$"
"""Regex pattern for TrEBML ID with optional '_1' or '_2' for multi-KD."""
TrEMBLIDSuffix = constr(pattern=TrEMBLPatternSuffix)
"""Pydantic model for TrEMBL ID constraints with suffix."""
[docs]
class TemplateSource(StrEnum):
"""Enum class for template source."""
PDB70 = "PDB70"
activeAF2 = "activeAF2"
activePDB = "activePDB"
none = "notemp"
[docs]
class MSASource(StrEnum):
"""Enum class for MSA source."""
family = "family"
ortholog = "ortholog"
uniref90 = "uniref90"
[docs]
class KinHub(BaseModel):
"""Pydantic model for KinHub information."""
model_config = ConfigDict(use_enum_values=True)
hgnc_name: str | None = None
kinase_name: str | None = None
manning_name: str
xname: str
group: Group
family: Family
[docs]
class UniProt(BaseModel):
"""Pydantic model for UniProt information."""
header: str
canonical_seq: SeqUniProt
phospho_sites: list[int] | None = None
phospho_evidence: list[set[str]] | None = None
phospho_description: list[str] | None = None
[docs]
class KLIFS(BaseModel):
"""Pydantic model for KLIFS information."""
model_config = ConfigDict(use_enum_values=True)
gene_name: str
name: str
full_name: str
group: Group
family: Family
iuphar: int
kinase_id: int
pocket_seq: SeqKLIFS | None = None
[docs]
class Pfam(BaseModel):
"""Pydantic model for Pfam information."""
model_config = ConfigDict(use_enum_values=True)
domain_name: KinaseDomainName
start: int
end: int
protein_length: int
pfam_accession: str
in_alphafold: bool
[docs]
class KinCoreFASTA(BaseModel):
"""Pydantic model for KinCore FASTA information."""
model_config = ConfigDict(use_enum_values=True)
seq: SeqUniProt
group: Group
hgnc: set[str]
swissprot: str
uniprot: SwissProtID | TrEMBLID
start_md: int # Modi-Dunbrack, 2019
end_md: int # Modi-Dunbrack, 2019
length_md: int | None = None # Modi-Dunbrack, 2019
start_af2: int | None = None # AF2 active state
end_af2: int | None = None # AF2 active state
length_af2: int | None = None # AF2 active state
length_uniprot: int | None = None # AF2 active state
source_file: str
start: int | None = None # fasta2uniprot
end: int | None = None # fasta2uniprot
mismatch: list[int] | None = None # fasta2uniprot
[docs]
class KinCoreCIF(BaseModel):
"""Pydantic model for KinCore CIF information."""
model_config = ConfigDict(use_enum_values=True)
cif: dict[str, str | list[str]]
group: Group
hgnc: str
min_aloop_pLDDT: float
template_source: TemplateSource
msa_size: int
msa_source: MSASource
model_no: int = Field(..., ge=1, lt=6)
start: int | None = None # cif2uniprot
end: int | None = None # cif2uniprot
mismatch: list[int] | None = None # cif2uniprot
[docs]
class KinCore(BaseModel):
"""Pydantic model for KinCore information."""
fasta: KinCoreFASTA
cif: KinCoreCIF | None = None
start: int | None = None # fasta2cif
end: int | None = None # fasta2cif
mismatch: list[int] | None = None # fasta2cif
[docs]
class KinaseInfoUniProt(BaseModel):
"""Pydantic model for kinase information at the level of the UniProt ID."""
hgnc_name: str
uniprot_id: SwissProtID | TrEMBLID
uniprot: UniProt
pfam: Pfam | None = None
[docs]
class KinaseInfoKinaseDomain(BaseModel):
"""Pydantic model for kinase information at the level of the kinase domain."""
uniprot_id: SwissProtIDSuffix | TrEMBLIDSuffix
kinhub: KinHub | None = None
klifs: KLIFS | None = None
kincore: KinCore | None = None
[docs]
class KinaseInfo(BaseModel):
"""Pydantic model for kinase information at the level of the kinase domain."""
hgnc_name: str
uniprot_id: SwissProtIDSuffix | TrEMBLIDSuffix
uniprot: UniProt
kinhub: KinHub | None = None
klifs: KLIFS | None = None
pfam: Pfam | None = None
kincore: KinCore | None = None
KLIFS2UniProtIdx: dict[str, int | None] | None = None
KLIFS2UniProtSeq: dict[str, str | None] | None = None
[docs]
@field_validator("KLIFS2UniProtIdx", mode="before")
@classmethod
def validate_klifs2uniprotidx(
cls,
value: dict[str, int | None] | None,
) -> dict[str, int | None] | None:
"""Validate KLIFS2UniProtIdx dictionary to include all regions since TOML doesn't save None.
Parameters
----------
value : dict[str, int | None]
Dictionary mapping KLIFS residue to UniProt indices.
Returns
-------
dict[str, int | None]
Dictionary mapping KLIFS residue to UniProt indices.
"""
dict_temp = dict.fromkeys(LIST_KLIFS_REGION, None)
if value is not None:
for key, val in value.items():
dict_temp[key] = val
return dict_temp
else:
return None
[docs]
@field_validator("KLIFS2UniProtSeq", mode="before")
@classmethod
def validate_klifs2uniprotseq(
cls,
value: dict[str, str | None] | None,
) -> dict[str, str | None] | None:
"""Validate KLIFS2UniProtSeq dictionary to include all regions since TOML doesn't save None.
Parameters
----------
value : dict[str, str | None]
Dictionary mapping KLIFS residue to UniProt residue.
Returns
-------
dict[str, str | None]
Dictionary mapping KLIFS residue to UniProt residue.
"""
dict_temp = dict.fromkeys(LIST_FULL_KLIFS_REGION, None)
if value is not None:
for key, val in value.items():
dict_temp[key] = val
return dict_temp
else:
return None
[docs]
def adjudicate_kd_sequence(self, bool_verbose: bool = False) -> str | None:
"""Adjudicate kinase domain sequence based on available data.
Parameters
----------
bool_verbose : bool, optional
Whether to log verbose messages, by default False.
Returns
-------
str | None
The kinase domain sequence if available, otherwise None.
"""
if self.kincore is not None:
seq = self.extract_sequence_from_cif(bool_verbose=bool_verbose)
if seq is not None:
return seq
else:
# all non-None entries will have fastas
return self.kincore.fasta.seq
elif self.pfam is not None:
return self.uniprot.canonical_seq[self.pfam.start - 1 : self.pfam.end]
else:
if bool_verbose:
logger.info(f"No kinase domain sequence found for {self.hgnc_name}")
return None
[docs]
def _klifs_uniprot_idx_bounds(self) -> tuple[int, int] | None:
"""Return the min and max non-None UniProt indices in KLIFS2UniProtIdx.
Returns
-------
tuple[int, int] | None
The (min, max) UniProt indices spanned by the KLIFS pocket if any
are available, otherwise None.
"""
if self.KLIFS2UniProtIdx is None:
return None
list_idx = [idx for idx in self.KLIFS2UniProtIdx.values() if idx is not None]
if len(list_idx) == 0:
return None
return min(list_idx), max(list_idx)
[docs]
def _reconcile_kd_bound_with_klifs(
self,
bound: int,
klifs_bound: int,
is_start: bool,
int_max_gap: int,
bool_verbose: bool,
) -> int | None:
"""Reconcile an adjudicated kinase domain bound with the KLIFS pocket.
The KLIFS pocket should fall within the kinase domain, i.e. the minimum
KLIFS index should be >= the kinase domain start and the maximum KLIFS
index should be <= the kinase domain end. When this is violated, the gap
is compared against ``int_max_gap``: small gaps expand the bound to the
KLIFS index, larger gaps return None since the mapping is unreliable.
Parameters
----------
bound : int
The adjudicated kinase domain bound (start or end).
klifs_bound : int
The corresponding KLIFS pocket bound (min for start, max for end).
is_start : bool
Whether ``bound`` is the kinase domain start (True) or end (False).
int_max_gap : int
Maximum allowed gap between the kinase domain bound and the KLIFS
bound before the bound is treated as unreliable and None is returned.
bool_verbose : bool
Whether to log verbose messages.
Returns
-------
int | None
The reconciled bound, expanded to the KLIFS index when the gap is
within ``int_max_gap``, otherwise None.
"""
str_bound = "start" if is_start else "end"
# violation is min < start (start) or max > end (end)
gap = bound - klifs_bound if is_start else klifs_bound - bound
if gap <= 0:
return bound
if gap <= int_max_gap:
if bool_verbose:
logger.info(
f"KLIFS pocket {str_bound} ({klifs_bound}) extends past kinase "
f"domain {str_bound} ({bound}) for {self.hgnc_name} by {gap}; "
f"expanding {str_bound} to {klifs_bound}."
)
return klifs_bound
logger.warning(
f"Kinase domain {str_bound} found for {self.hgnc_name} but KLIFS pocket "
f"{str_bound} ({klifs_bound}) gap is {gap} (larger than cut-off "
f"{int_max_gap}); returning None."
)
return None
[docs]
def adjudicate_kd_start(
self, int_max_gap: int = 15, bool_verbose: bool = False
) -> int | None:
"""Adjudicate kinase domain start based on available data.
Parameters
----------
int_max_gap : int, optional
Maximum allowed gap between the kinase domain start and the minimum
KLIFS pocket index before the start is treated as unreliable and None
is returned, by default 15.
bool_verbose : bool, optional
Whether to log verbose messages, by default False.
Returns
-------
int | None
The start of the kinase domain if available, otherwise None.
"""
if self.kincore is not None:
start = rgetattr(self, "kincore.cif.start") or rgetattr(
self, "kincore.fasta.start"
)
elif self.pfam is not None:
start = self.pfam.start
else:
if bool_verbose:
logger.info(
f"No kinase domain sequence start found for {self.hgnc_name}"
)
return None
bounds = self._klifs_uniprot_idx_bounds()
if start is not None and bounds is not None:
start = self._reconcile_kd_bound_with_klifs(
bound=start,
klifs_bound=bounds[0],
is_start=True,
int_max_gap=int_max_gap,
bool_verbose=bool_verbose,
)
return start
[docs]
def adjudicate_kd_end(
self, int_max_gap: int = 15, bool_verbose: bool = False
) -> int | None:
"""Adjudicate kinase domain end based on available data.
Parameters
----------
int_max_gap : int, optional
Maximum allowed gap between the kinase domain end and the maximum
KLIFS pocket index before the end is treated as unreliable and None
is returned, by default 15.
bool_verbose : bool, optional
Whether to log verbose messages, by default False.
Returns
-------
int | None
The end of the kinase domain if available, otherwise None.
"""
if self.kincore is not None:
end = rgetattr(self, "kincore.cif.end") or rgetattr(
self, "kincore.fasta.end"
)
elif self.pfam is not None:
end = self.pfam.end
else:
if bool_verbose:
logger.info(f"No kinase domain sequence end found for {self.hgnc_name}")
return None
bounds = self._klifs_uniprot_idx_bounds()
if end is not None and bounds is not None:
end = self._reconcile_kd_bound_with_klifs(
bound=end,
klifs_bound=bounds[1],
is_start=False,
int_max_gap=int_max_gap,
bool_verbose=bool_verbose,
)
return end
[docs]
def adjudicate_group(self, bool_verbose: bool = False) -> str | None:
"""Adjudicate group based on available data.
Parameters
----------
bool_verbose : bool, optional
Whether to log verbose messages, by default False.
Returns
-------
str | None
The group of the kinase if available, otherwise None.
"""
list_attr = ["kincore.fasta.group", "kinhub.group", "klifs.group"]
for attr in list_attr:
group = rgetattr(self, attr)
if group is not None:
return group
if bool_verbose:
logger.info(f"No group found for {self.hgnc_name}")
return None
[docs]
def is_lipid_kinase(self) -> bool:
"""Return boolean if a lipid kinase.
Returns
-------
bool
Whether or not is a lipid kinase
"""
str_hgnc = self.hgnc_name.split("_")[0]
bool1 = str_hgnc.startswith("PI")
bool2 = not (str_hgnc.startswith("PIM") or str_hgnc.startswith("PIN"))
# protein kinase
# previously included "PI4KAP1" and "PI4KAP2" since pseudogenes but they're lipid
bool3 = not (str_hgnc in ["PIK3R4"])
if bool1 and bool2 and bool3:
return True
else:
return False
[docs]
def is_pseudogene(self) -> bool:
"""Return boolean if a pseudogene.
Returns
-------
bool
Whether or not is a pseudogene
"""
from mkt.schema.utils import rgetattr
for attr, str_attr in [
("uniprot.header", "putative"),
("klifs.full_name", "pseudogene"),
]:
val = rgetattr(self, attr)
if val is not None and str_attr in val.lower():
return True
return False
[docs]
def is_pseudokinase(self) -> bool:
"""Return boolean if a (predicted) pseudokinase.
Predicts catalytic deficiency from the KLIFS pocket by testing the three
canonical catalytic residues -- the VAIK beta3 lysine (III:17), the HRD
catalytic aspartate (c.l:70) and the DFG aspartate (xDFG:81); a kinase missing
any one is called a pseudokinase. The catalytic lysine may instead sit in beta2
(II:13) in the WNK family, which is accepted as present. Two hand-curated
overrides correct the known failure modes of the heuristic (see
``LIST_PSEUDOKINASE_TRIAD_INTACT`` and
``LIST_PSEUDOKINASE_HEURISTIC_FALSE_POSITIVE`` in ``mkt.schema.constants`` for
membership and citations). Returns False when no KLIFS pocket is available.
Returns
-------
bool
Whether or not is a predicted pseudokinase
"""
from mkt.schema.constants import (
LIST_KLIFS_REGION,
LIST_PSEUDOKINASE_HEURISTIC_FALSE_POSITIVE,
LIST_PSEUDOKINASE_TRIAD_INTACT,
STR_KLIFS_BETA2_LYSINE,
STR_KLIFS_BETA3_LYSINE,
STR_KLIFS_CATALYTIC_ASP,
STR_KLIFS_DFG_ASP,
)
# curated literature overrides take precedence over the sequence heuristic
if self.hgnc_name in LIST_PSEUDOKINASE_TRIAD_INTACT:
return True
if self.hgnc_name in LIST_PSEUDOKINASE_HEURISTIC_FALSE_POSITIVE:
return False
# cannot assess catalytic residues without a pocket
if self.klifs is None or self.klifs.pocket_seq is None:
return False
pocket = self.klifs.pocket_seq
def _residue(label):
return pocket[LIST_KLIFS_REGION.index(label)]
# the catalytic lysine is normally in beta3 (VAIK); the WNK family relocates it
# to beta2 ("With No K [in beta3]"), so accept a lysine at either position
has_lysine = (
_residue(STR_KLIFS_BETA3_LYSINE) == "K"
or _residue(STR_KLIFS_BETA2_LYSINE) == "K"
)
has_catalytic_asp = _residue(STR_KLIFS_CATALYTIC_ASP) == "D"
has_dfg_asp = _residue(STR_KLIFS_DFG_ASP) == "D"
# a pseudokinase is missing at least one of the three catalytic residues
return not (has_lysine and has_catalytic_asp and has_dfg_asp)
[docs]
def return_molecular_brake_residues(self) -> dict[str, str | None] | None:
"""Return this kinase's residues at the molecular brake KLIFS positions.
The molecular brake is a network of conserved residues in the KLIFS pocket
(see ``DICT_MOLECULAR_BRAKE`` in ``mkt.schema.constants`` for the region:idx
labels and their canonical identities). This reads the residue at each of
those positions from the KLIFS-to-UniProt index mapping, i.e.
``canonical_seq[KLIFS2UniProtIdx[label] - 1 + offset]``, where the per-position
offset comes from ``DICT_MOLECULAR_BRAKE_OFFSET`` (the brake lysine sits one
residue N-terminal to its VIII:79 KLIFS-aligned position).
Returns
-------
dict[str, str | None] | None
Dictionary mapping each molecular brake KLIFS region:idx label to the
residue found at that position in this kinase, or None where the position
is unmapped. Returns None entirely when no KLIFS pocket mapping is
available (``KLIFS2UniProtIdx`` is None).
"""
from mkt.schema.constants import (
DICT_MOLECULAR_BRAKE,
DICT_MOLECULAR_BRAKE_OFFSET,
)
if self.KLIFS2UniProtIdx is None:
return None
seq = self.uniprot.canonical_seq
dict_residues: dict[str, str | None] = {}
for label in DICT_MOLECULAR_BRAKE:
idx = self.KLIFS2UniProtIdx.get(label)
if idx is None:
dict_residues[label] = None
continue
offset = DICT_MOLECULAR_BRAKE_OFFSET.get(label, 0)
dict_residues[label] = seq[idx - 1 + offset]
return dict_residues
[docs]
def check_molecular_brake_against_canonical(
self,
) -> tuple[bool, bool, bool] | None:
"""Check this kinase's molecular brake residues against the canonical identities.
Compares the residue at each molecular brake position (see
``return_molecular_brake_residues``) against the conserved canonical residue in
``DICT_MOLECULAR_BRAKE``. An unmapped position (None) is treated as not matching.
Returns
-------
tuple[bool, bool, bool] | None
One boolean per molecular brake position -- in ``DICT_MOLECULAR_BRAKE`` order
-- indicating whether this kinase's residue matches the canonical identity.
Returns None when no KLIFS pocket mapping is available.
"""
from mkt.schema.constants import DICT_MOLECULAR_BRAKE
dict_residues = self.return_molecular_brake_residues()
if dict_residues is None:
return None
return tuple(
dict_residues[label] == canonical
for label, canonical in DICT_MOLECULAR_BRAKE.items()
)