Samad14's picture
Fix pipeline MSA/AI stall, add SwissADME-parity ADMET panel, PubChem search, docking ligand data
5bb077e
Raw History Blame Contribute Delete
6.27 kB
"""In-process progressive MSA fallback (pure Biopython).
Used when every EBI MSA endpoint is unreachable or times out, so the pipeline
never stalls on the MSA step. Produces a sensible progressive alignment plus an
UPGMA guide-tree Newick. Marked as ``method: "in-process fallback"`` in the
result so the UI can indicate it is not a Clustal Omega alignment.
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
def _aligner(stype: str):
from Bio.Align import PairwiseAligner
from Bio.Align import substitution_matrices
aligner = PairwiseAligner()
aligner.mode = "global"
if stype == "protein":
try:
aligner.substitution_matrix = substitution_matrices.load("BLOSUM62")
except Exception:
aligner.substitution_matrix = None
aligner.match_score = 1.0
aligner.mismatch_score = -1.0
aligner.open_gap_score = -11.0
aligner.extend_gap_score = -1.0
else:
aligner.substitution_matrix = None
aligner.match_score = 2.0
aligner.mismatch_score = -1.0
aligner.open_gap_score = -2.0
aligner.extend_gap_score = -0.5
return aligner
def _pairwise(aligner, s1: str, s2: str) -> tuple[str, str]:
"""Return (gapped_s1, gapped_s2) from the best global alignment."""
aln = aligner.align(s1, s2)[0]
ncol = int(aln.shape[1])
return _gapped(s1, aln.aligned[0], ncol), _gapped(s2, aln.aligned[1], ncol)
def _gapped(seq: str, aligned, ncol: int) -> str:
"""Turn an alignment block list (start, end) pairs into a gapped string of
exactly ``ncol`` columns (pad for un-aligned overhangs)."""
out: list[str] = []
prev = 0
for start, end in aligned:
out.append("-" * (start - prev))
out.append(seq[start:end])
prev = end
out.append("-" * (ncol - len("".join(out))))
return "".join(out)
def _identity(g1: str, g2: str) -> float:
aligned = sum(1 for a, b in zip(g1, g2) if a != "-" and b != "-")
if aligned == 0:
return 0.0
matches = sum(1 for a, b in zip(g1, g2) if a == b and a != "-")
return matches / aligned
def _upgma_newick(labels: list[str], dist: list[list[float]]) -> str:
"""UPGMA clustering to a Newick tree. Falls back to a star tree on error."""
try:
n = len(labels)
d = {i: {j: dist[i][j] for j in range(n)} for i in range(n)}
size = {i: 1 for i in range(n)}
names = {i: _safe_label(labels[i]) for i in range(n)}
active = set(range(n))
next_id = n
while len(active) > 1:
best = None
for i in active:
for j in active:
if i < j and (best is None or d[i][j] < best[0]):
best = (d[i][j], i, j)
if best is None:
break
_, i, j = best
si, sj = size[i], size[j]
merged = next_id
next_id += 1
d[merged] = {}
for k in active:
if k in (i, j):
continue
d[merged][k] = d[k][merged] = (d[i][k] * si + d[j][k] * sj) / (si + sj)
names[merged] = f"({names[i]}:{d[i][j]/2:.4f},{names[j]}:{d[i][j]/2:.4f})"
size[merged] = si + sj
active.remove(i)
active.remove(j)
active.add(merged)
root = active.pop()
return names[root] + ";"
except Exception as e: # never let a tree build error block the fallback
logger.warning("UPGMA Newick build failed, using star tree: %s", e)
return _star_newick(labels)
def _safe_label(label: str) -> str:
clean = "".join(c for c in label if c.isalnum() or c in "_.")
return clean or "seq"
def _star_newick(labels: list[str]) -> str:
leaves = ",".join(_safe_label(l) for l in labels)
return f"({leaves});" if labels else "(root);"
def _expand(row: str, c_aln: str) -> str:
"""Map an existing profile row (old consensus columns) onto a new alignment
of the consensus against a new sequence. Every letter in ``c_aln`` consumes
one old column; every gap inserts a new gap column."""
out = []
ci = 0
for ch in c_aln:
if ch == "-":
out.append("-")
else:
out.append(row[ci] if ci < len(row) else "-")
ci += 1
return "".join(out)
def _consensus(rows: list[str]) -> str:
L = len(rows[0])
cons: list[str] = []
for col in range(L):
counts: dict[str, int] = {}
for r in rows:
c = r[col]
if c != "-":
counts[c] = counts.get(c, 0) + 1
cons.append(max(counts, key=counts.get) if counts else "X")
return "".join(cons)
def progressive_msa(sequences: list[tuple[str, str]], stype: str = "protein") -> tuple[str, str]:
"""Return ``(fasta, newick)`` from a pure-Biopython progressive MSA."""
ids = [s[0] for s in sequences]
seqs = [s[1] for s in sequences]
n = len(seqs)
if n == 0:
raise ValueError("No sequences to align")
aligner = _aligner(stype)
if n == 1:
fasta = _to_fasta([(ids[0], seqs[0])])
return fasta, _star_newick(ids)
# Distance matrix (1 - pairwise identity)
dist = [[0.0] * n for _ in range(n)]
for i in range(n):
for j in range(i + 1, n):
g1, g2 = _pairwise(aligner, seqs[i], seqs[j])
dist[i][j] = dist[j][i] = 1.0 - _identity(g1, g2)
newick = _upgma_newick(ids, dist)
# Progressive alignment: align each sequence against the current consensus
# and gap-transfer the result onto every already-added profile row.
rows = [seqs[0]]
cons = seqs[0]
for s in seqs[1:]:
c_aln, s_aln = _pairwise(aligner, cons, s)
rows = [_expand(row, c_aln) for row in rows]
rows.append(s_aln)
cons = _consensus(rows)
fasta = _to_fasta([(ids[k], rows[k]) for k in range(n)])
return fasta, newick
def _to_fasta(seqs: list[tuple[str, str]], width: int = 80) -> str:
lines: list[str] = []
for sid, sseq in seqs:
lines.append(f">{sid}")
for i in range(0, len(sseq), width):
lines.append(sseq[i : i + width])
return "\n".join(lines)