bio-nexus-api / bioai-platform /backend /app /tools /motif_scanner.py
Samad14's picture
feat(seq-tools): motif categories & structure overlay, dot-plot scoring & features, cross-tool links
033540e
Raw History Blame Contribute Delete
12.9 kB
"""Local protein motif scanner.
Implements the two scanning modes a bench biologist uses to find short
functional motifs in a protein:
* Custom PROSITE patterns (e.g. ``[ST]-x-[RK]``, ``N-{P}-[ST]-{P}``) parsed
into a Python regular expression and matched against the query.
* A curated library of well-known eukaryotic motif patterns (glycosylation,
phosphorylation, myristoylation, zinc fingers, the P-loop, ...).
Pure local computation — no network calls. This is the "scan your sequence
for motifs" workflow that PROSITE/ScanProsite expose as a web service.
"""
from __future__ import annotations
import re
from app.services.sequence_utils import detect_sequence_type
class MotifError(ValueError):
pass
# Well-known PROSITE-style patterns, organized by biological category.
# accession — PROSITE entry (PSxxxxx) when the pattern maps to a real
# curated PROSITE profile; "" for consensus motifs that are
# commonly used but not part of the PROSITE database.
# specificity — "high" for rare, low-false-positive patterns (zinc fingers,
# P-loop, active sites); "loose" for short, high-frequency
# consensus sites (most phosphorylation/glycosylation sites).
# category — groups patterns for the UI: PTM / DNA binding / RNA binding
# / Catalytic / Signal & targeting / Structural.
MOTIF_LIBRARY: dict[str, dict[str, str]] = {
# --- Post-translational modifications --------------------------------
"N-glycosylation site": {
"accession": "PS00001",
"category": "PTM",
"specificity": "high",
"description": "Asn-linked glycosylation: N-{P}-[ST]-{P}",
"pattern": "N-{P}-[ST]-{P}",
},
"Tyrosine sulfation site": {
"accession": "PS00003",
"category": "PTM",
"specificity": "loose",
"description": "Tyrosine sulfation consensus: [ED]-x(2)-[DE]-x(2)-Y",
"pattern": "[ED]-x(2)-[DE]-x(2)-Y",
},
"cAMP/cGMP kinase site": {
"accession": "PS00004",
"category": "PTM",
"specificity": "loose",
"description": "cAMP/cGMP-dependent protein kinase phosphorylation: [RK]-{2}-x-[ST]",
"pattern": "[RK]-{2}-x-[ST]",
},
"Protein kinase C site": {
"accession": "PS00005",
"category": "PTM",
"specificity": "loose",
"description": "Protein kinase C phosphorylation: [ST]-x-[RK]",
"pattern": "[ST]-x-[RK]",
},
"Casein kinase II site": {
"accession": "PS00006",
"category": "PTM",
"specificity": "loose",
"description": "Casein kinase II phosphorylation: [ST]-x(2)-[DE]",
"pattern": "[ST]-x(2)-[DE]",
},
"Tyrosine kinase site": {
"accession": "PS00007",
"category": "PTM",
"specificity": "loose",
"description": "Tyrosine kinase phosphorylation: [RK]-x(2,3)-[DE]-x(2,3)-Y",
"pattern": "[RK]-x(2,3)-[DE]-x(2,3)-Y",
},
"N-myristoylation site": {
"accession": "PS00008",
"category": "PTM",
"specificity": "loose",
"description": "Myristyl anchor: G-{EDRKHPFYW}-x(2)-[STAGCN]-{P}",
"pattern": "G-{EDRKHPFYW}-x(2)-[STAGCN]-{P}",
},
"Amidation site": {
"accession": "PS00009",
"category": "PTM",
"specificity": "loose",
"description": "C-terminal amidation signal: x-G-[RK]-x",
"pattern": "x-G-[RK]-x",
},
# --- DNA binding ------------------------------------------------------
"C2H2 zinc finger": {
"accession": "PS00028",
"category": "DNA binding",
"specificity": "high",
"description": "Classic zinc finger half: C-x(2,4)-C-x(3)-[LIVMFYWC]-x(8)-H-x(3,5)-H",
"pattern": "C-x(2,4)-C-x(3)-[LIVMFYWC]-x(8)-H-x(3,5)-H",
},
"Leucine zipper": {
"accession": "PS00029",
"category": "DNA binding",
"specificity": "loose",
"description": "Heptad repeat dimerization: L-x(6)-L-x(6)-L-x(6)-L",
"pattern": "L-x(6)-L-x(6)-L-x(6)-L",
},
"Nuclear hormone receptor C4 finger": {
"accession": "PS00030",
"category": "DNA binding",
"specificity": "high",
"description": "C4 zinc finger of steroid hormone receptors: C-x(2)-C-x(13)-C-x(2)-C",
"pattern": "C-x(2)-C-x(13)-C-x(2)-C",
},
# --- RNA binding ------------------------------------------------------
"RNP-1 RNA-binding region": {
"accession": "PS00031",
"category": "RNA binding",
"specificity": "high",
"description": "RNP-1 motif of RNA-recognition proteins: [RK]-G-{P}-[FY]-{V}-x-[FYWH]-[LIVMFY]",
"pattern": "[RK]-G-{P}-[FY]-{V}-x-[FYWH]-[LIVMFY]",
},
# --- Catalytic / nucleotide binding ----------------------------------
"ATP/GTP-binding P-loop": {
"accession": "PS00017",
"category": "Catalytic",
"specificity": "high",
"description": "Walker A / P-loop: [AG]-x(4)-G-K-[ST]",
"pattern": "[AG]-x(4)-G-K-[ST]",
},
"Protein kinase ATP-binding": {
"accession": "PS00107",
"category": "Catalytic",
"specificity": "high",
"description": "Protein kinase ATP-binding signature: [LIVMFYC]-x-[HY]-x-D-[LIVMFY]-K-x(2)-N-[LIVMFYCT]",
"pattern": "[LIVMFYC]-x-[HY]-x-D-[LIVMFY]-K-x(2)-N-[LIVMFYCT]",
},
"Serine protease active site": {
"accession": "PS00138",
"category": "Catalytic",
"specificity": "high",
"description": "Chymotrypsin-family catalytic Ser: [LIVMFYWC]-G-[HYW]-S-[LIVMFYWC]-G-x(2)-[SAG]",
"pattern": "[LIVMFYWC]-G-[HYW]-S-[LIVMFYWC]-G-x(2)-[SAG]",
},
"Aspartic protease active site": {
"accession": "PS00141",
"category": "Catalytic",
"specificity": "high",
"description": "Aspartic protease catalytic Asp: [LIVMFGAC]-[LIVMTADN]-[LIVFSA]-D-[STAG]-G-[STAV]-[STAPDENQ]-x(2)-[LIVMFSTNC]-x-[LIVMFGTA]",
"pattern": "[LIVMFGAC]-[LIVMTADN]-[LIVFSA]-D-[STAG]-G-[STAV]-[STAPDENQ]-x(2)-[LIVMFSTNC]-x-[LIVMFGTA]",
},
# --- Signal & targeting -----------------------------------------------
"Nuclear localization signal (NLS)": {
"accession": "",
"category": "Signal & targeting",
"specificity": "loose",
"description": "Monopartite NLS consensus: K-[RK]-x(2)-[KRA]",
"pattern": "K-[RK]-x(2)-[KRA]",
},
"ER retention signal": {
"accession": "",
"category": "Signal & targeting",
"specificity": "loose",
"description": "C-terminal endoplasmic-reticulum retention: [KRHQSA]-[DENQ]-E-L>",
"pattern": "[KRHQSA]-[DENQ]-E-L>",
},
"Secretory signal peptide": {
"accession": "",
"category": "Signal & targeting",
"specificity": "loose",
"description": "Hydrophobic N-terminal core of a cleavable signal peptide: M-x-[LIVMF]-x(3)-[LIVMF]",
"pattern": "M-x-[LIVMF]-x(3)-[LIVMF]",
},
# --- Structural / adhesion --------------------------------------------
"RGD cell-attachment": {
"accession": "PS00016",
"category": "Structural",
"specificity": "high",
"description": "Integrin-binding tripeptide: R-G-D",
"pattern": "R-G-D",
},
}
CATEGORY_ORDER = ["PTM", "DNA binding", "RNA binding", "Catalytic", "Signal & targeting", "Structural"]
def list_motif_categories() -> list[str]:
"""Return the ordered list of pattern categories present in the library."""
return [c for c in CATEGORY_ORDER if any(e["category"] == c for e in MOTIF_LIBRARY.values())]
def get_motif_patterns() -> list[dict]:
"""Return the full library as a list (for presets / API listings)."""
return [
{
"name": name,
"accession": entry["accession"],
"category": entry["category"],
"specificity": entry["specificity"],
"description": entry["description"],
"pattern": entry["pattern"],
}
for name, entry in MOTIF_LIBRARY.items()
]
def prosite_to_regex(pattern: str) -> str:
"""Translate a PROSITE pattern string into a Python regular expression.
Supported syntax: ``-`` (separator, ignored), ``x`` (any residue),
``[AC]`` (one of), ``{ED}`` (none of), ``x(4)`` / ``x(2,4)``
(repetition), ``<`` (N-terminus) and ``>`` (C-terminus).
"""
pattern = (pattern or "").strip()
if not pattern:
raise MotifError("Pattern is empty")
i = 0
n = len(pattern)
out: list[str] = []
def consume_repeat() -> None:
nonlocal i
if i < n and pattern[i] == "(":
j = pattern.find(")", i + 1)
if j == -1:
raise MotifError(f"Unterminated repeat quantifier at position {i}")
quant = pattern[i + 1:j]
if not re.fullmatch(r"\d+(,\d+)?", quant):
raise MotifError(f"Invalid repeat quantifier ({quant}) at position {i}")
out.append("{" + quant + "}")
i = j + 1
while i < n:
c = pattern[i]
if c == "-":
i += 1
elif c == "<":
out.append("^")
i += 1
elif c == ">":
out.append("$")
i += 1
elif c == "[":
j = pattern.find("]", i + 1)
if j == -1:
raise MotifError(f"Unterminated '[' group at position {i}")
group = pattern[i + 1:j]
if not group or any(ch not in "ACDEFGHIKLMNPQRSTVWY" for ch in group):
raise MotifError(f"Invalid residue group [{group}] at position {i}")
out.append("[" + group + "]")
i = j + 1
consume_repeat()
elif c == "{":
j = pattern.find("}", i + 1)
if j == -1:
raise MotifError(f"Unterminated '{{' group at position {i}")
group = pattern[i + 1:j]
if not group or any(ch not in "ACDEFGHIKLMNPQRSTVWY" for ch in group):
raise MotifError(f"Invalid exclusion group {{{group}}} at position {i}")
out.append("[^" + group + "]")
i = j + 1
consume_repeat()
elif c in ("x", "X"):
out.append("[A-Z]")
i += 1
consume_repeat()
elif c in "ACDEFGHIKLMNPQRSTVWY":
out.append(c)
i += 1
consume_repeat()
else:
raise MotifError(f"Unsupported symbol {c!r} at position {i}")
return "".join(out)
def _clean_protein(seq: str) -> str:
return "".join(ch for ch in seq.upper() if ch.isalpha())
def scan_pattern(sequence: str, pattern: str) -> dict:
"""Scan a protein for a single PROSITE pattern."""
seq = _clean_protein(sequence)
if not seq:
raise MotifError("Sequence is empty")
regex_src = prosite_to_regex(pattern)
try:
regex = re.compile(regex_src)
except re.error as e:
raise MotifError(f"Pattern compiles to an invalid regular expression: {e}")
matches = []
for m in regex.finditer(seq):
matches.append({
"start": m.start() + 1,
"end": m.end(),
"motif": m.group(0),
})
return {
"sequence_type": detect_sequence_type(seq),
"pattern": pattern,
"regex": regex_src,
"count": len(matches),
"matches": matches,
}
def scan_library(sequence: str, categories: list[str] | None = None) -> dict:
"""Scan a protein against the curated PROSITE library.
``categories`` optionally restricts the scan to a subset of categories
(e.g. ``["PTM", "DNA binding"]``). Unknown category names are ignored.
"""
seq = _clean_protein(sequence)
if not seq:
raise MotifError("Sequence is empty")
allowed = set(categories or [])
scanned = 0
hits = []
for name, entry in MOTIF_LIBRARY.items():
if allowed and entry["category"] not in allowed:
continue
scanned += 1
try:
result = scan_pattern(seq, entry["pattern"])
except MotifError:
continue
if result["count"] > 0:
hits.append({
"name": name,
"accession": entry["accession"],
"category": entry["category"],
"specificity": entry["specificity"],
"description": entry["description"],
"pattern": entry["pattern"],
"count": result["count"],
"matches": result["matches"],
})
hits.sort(key=lambda h: (-h["count"], h["name"]))
return {
"sequence_type": detect_sequence_type(seq),
"length": len(seq),
"patterns_scanned": scanned,
"motifs_found": len(hits),
"hits": hits,
}