bio-nexus-api / app /tools /pairwise_alignment.py
Samad14's picture
Upload folder using huggingface_hub (part 2)
8c636b5 verified
Raw History Blame Contribute Delete
6.11 kB
"""In-process pairwise alignment (Needleman-Wunsch / Smith-Waterman)."""
from __future__ import annotations
from Bio.Align import PairwiseAligner, substitution_matrices
VALID_MODES = ("global", "local")
MATRICES = {"blosum62": "BLOSUM62", "pam250": "PAM250"}
class PairwiseAlignError(ValueError):
pass
def _normalize_sequence(seq: str, label: str) -> str:
body_lines = [line for line in (seq or "").splitlines() if not line.lstrip().startswith(">")]
body = "".join(body_lines)
chars: list[str] = []
position = 0
for character in body:
if character.isspace():
continue
position += 1
if not character.isalpha():
raise PairwiseAlignError(f"{label} contains invalid character {character!r} at position {position}")
chars.append(character.upper())
normalized = "".join(chars)
if not normalized:
raise PairwiseAlignError(f"{label} sequence is empty")
return normalized
def _gap_runs(aligned: str, seq_label: str, residue_offset: int = 0) -> list[dict]:
runs: list[dict] = []
residues_seen = residue_offset
index = 0
while index < len(aligned):
if aligned[index] == "-":
end = index
while end < len(aligned) and aligned[end] == "-":
end += 1
runs.append({"seq": seq_label, "inserted_after": residues_seen, "length": end - index})
index = end
else:
residues_seen += 1
index += 1
return runs
def pairwise_align(
seq_a: str,
seq_b: str,
mode: str = "global",
matrix: str = "blosum62",
open_gap_score: float = -10,
extend_gap_score: float = -1,
) -> dict:
mode = (mode or "global").lower()
if mode not in VALID_MODES:
raise PairwiseAlignError(f"mode must be one of {VALID_MODES}, got {mode!r}")
matrix = (matrix or "blosum62").lower()
if matrix not in MATRICES:
raise PairwiseAlignError(f"matrix must be one of {list(MATRICES)}, got {matrix!r}")
if open_gap_score > 0 or extend_gap_score > 0:
raise PairwiseAlignError("Gap scores must be penalties (zero or negative), not positive rewards")
seq_a = _normalize_sequence(seq_a, "query")
seq_b = _normalize_sequence(seq_b, "subject")
substitution_matrix = substitution_matrices.load(MATRICES[matrix])
allowed = set(str(substitution_matrix.alphabet)) - {"*"}
for label, sequence in (("query", seq_a), ("subject", seq_b)):
invalid = sorted(set(sequence) - allowed)
if invalid:
raise PairwiseAlignError(f"{label} contains residues unsupported by {matrix.upper()}: {', '.join(invalid)}")
aligner = PairwiseAligner()
aligner.mode = mode
aligner.substitution_matrix = substitution_matrix
aligner.open_gap_score = open_gap_score
aligner.extend_gap_score = extend_gap_score
alignments = aligner.align(seq_a, seq_b)
common = {
"mode": mode,
"matrix": matrix,
"open_gap_score": float(open_gap_score),
"extend_gap_score": float(extend_gap_score),
"query_length": len(seq_a),
"hit_length": len(seq_b),
}
if len(alignments) == 0:
return {
**common,
"score": 0.0,
"aligned_query": "",
"aligned_hit": "",
"alignment_length": 0,
"identity": 0,
"pct_identity": 0.0,
"similarity": 0,
"pct_similarity": 0.0,
"mismatches": 0,
"gaps_total": 0,
"gap_positions": [],
"query_start": 0,
"query_end": 0,
"hit_start": 0,
"hit_end": 0,
}
best = alignments[0]
aligned_a = str(best[0])
aligned_b = str(best[1])
alignment_length = len(aligned_a)
identity = 0
similarity = 0
mismatches = 0
for query_residue, subject_residue in zip(aligned_a, aligned_b):
if query_residue == "-" or subject_residue == "-":
continue
if query_residue == subject_residue:
identity += 1
else:
mismatches += 1
try:
if float(substitution_matrix[query_residue, subject_residue]) > 0:
similarity += 1
except (KeyError, IndexError):
pass
query_blocks, hit_blocks = best.aligned
if mode == "global":
# A global alignment always spans both whole sequences, so coordinates
# are the full 1..len range regardless of where matched blocks start.
query_start = 1
query_end = len(seq_a)
query_offset = 0
hit_start = 1
hit_end = len(seq_b)
hit_offset = 0
else:
if len(query_blocks):
query_start = int(query_blocks[0][0]) + 1
query_end = int(query_blocks[-1][1])
query_offset = int(query_blocks[0][0])
else:
query_start = query_end = query_offset = 0
if len(hit_blocks):
hit_start = int(hit_blocks[0][0]) + 1
hit_end = int(hit_blocks[-1][1])
hit_offset = int(hit_blocks[0][0])
else:
hit_start = hit_end = hit_offset = 0
gap_positions = [
run for run in _gap_runs(aligned_a, "query", query_offset) + _gap_runs(aligned_b, "subject", hit_offset)
if run["length"] > 0
]
gaps_total = sum(run["length"] for run in gap_positions)
return {
**common,
"score": float(best.score),
"aligned_query": aligned_a,
"aligned_hit": aligned_b,
"alignment_length": alignment_length,
"identity": identity,
"pct_identity": round(identity / alignment_length * 100, 1) if alignment_length else 0.0,
"similarity": similarity,
"pct_similarity": round(similarity / alignment_length * 100, 1) if alignment_length else 0.0,
"mismatches": mismatches,
"gaps_total": gaps_total,
"gap_positions": gap_positions,
"query_start": query_start,
"query_end": query_end,
"hit_start": hit_start,
"hit_end": hit_end,
}