File size: 6,273 Bytes
5bb077e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
"""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)