bioforge 2.3.0__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bioforge-2.3.0.data/purelib/bioforge/__init__.py +74 -0
- bioforge-2.3.0.data/purelib/bioforge/aligner.py +953 -0
- bioforge-2.3.0.data/purelib/bioforge/analyze.py +385 -0
- bioforge-2.3.0.data/purelib/bioforge/bgzf.py +119 -0
- bioforge-2.3.0.data/purelib/bioforge/biocore.py +2092 -0
- bioforge-2.3.0.data/purelib/bioforge/engine/__init__.py +1 -0
- bioforge-2.3.0.data/purelib/bioforge/engine/_loader.py +543 -0
- bioforge-2.3.0.data/purelib/bioforge/engine/build.py +231 -0
- bioforge-2.3.0.data/purelib/bioforge/engine/engine.c +1538 -0
- bioforge-2.3.0.data/purelib/bioforge/engine/engine.dll +0 -0
- bioforge-2.3.0.data/purelib/bioforge/qcreport.py +298 -0
- bioforge-2.3.0.data/purelib/bioforge/smart_translator.py +601 -0
- bioforge-2.3.0.dist-info/METADATA +736 -0
- bioforge-2.3.0.dist-info/RECORD +18 -0
- bioforge-2.3.0.dist-info/WHEEL +5 -0
- bioforge-2.3.0.dist-info/entry_points.txt +4 -0
- bioforge-2.3.0.dist-info/licenses/LICENSE +280 -0
- bioforge-2.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
"""
|
|
2
|
+
aligner.py
|
|
3
|
+
══════════════════════════════════════════════════════════════════════
|
|
4
|
+
Needleman-Wunsch pairwise sequence aligner — anti-diagonal wavefront.
|
|
5
|
+
|
|
6
|
+
Integrates with biocore.py / smart_translator.py (5-bit engine).
|
|
7
|
+
|
|
8
|
+
Vectorization strategy
|
|
9
|
+
──────────────────────
|
|
10
|
+
The NW recurrence H[i,j] = max(H[i-1,j-1]+s, H[i-1,j]+g, H[i,j-1]+g)
|
|
11
|
+
carries a cell-level data dependency that prevents full 2-D NumPy
|
|
12
|
+
vectorisation. Cells on the same anti-diagonal (i+j = d) are however
|
|
13
|
+
mutually independent, enabling:
|
|
14
|
+
|
|
15
|
+
• ONE outer Python loop over anti-diagonals → O(m+n) iterations
|
|
16
|
+
• NumPy-vectorised computation inside each → up to min(m,n) ops
|
|
17
|
+
|
|
18
|
+
This reduces Python-level iterations from O(m·n) to O(m+n).
|
|
19
|
+
|
|
20
|
+
The traceback is inherently sequential (O(m+n)) — the data dependency
|
|
21
|
+
cannot be removed at that stage either.
|
|
22
|
+
|
|
23
|
+
Scoring (linear gap model)
|
|
24
|
+
──────────────────────────
|
|
25
|
+
Match : +2
|
|
26
|
+
Mismatch : −1
|
|
27
|
+
Gap : −2 (uniform open+extend)
|
|
28
|
+
|
|
29
|
+
Memory constraint
|
|
30
|
+
─────────────────
|
|
31
|
+
The full DP matrix is O(m·n) int32. For sequences > 15 000 symbols
|
|
32
|
+
a UserWarning is emitted; banded alignment (not yet implemented)
|
|
33
|
+
would be the correct solution at chromosome scale.
|
|
34
|
+
|
|
35
|
+
Quick start
|
|
36
|
+
───────────
|
|
37
|
+
>>> from bioforge import SmartImporter, SeqType
|
|
38
|
+
>>> seqs = SmartImporter.from_string(fasta_a + fasta_b,
|
|
39
|
+
... force_type=SeqType.NUCLEOTIDE)
|
|
40
|
+
>>> result = SequenceAligner.align(seqs[0], seqs[1])
|
|
41
|
+
>>> print(format_alignment(result))
|
|
42
|
+
>>> for mut in result.mutations:
|
|
43
|
+
... print(mut)
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import warnings
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from typing import Literal
|
|
51
|
+
|
|
52
|
+
import numpy as np
|
|
53
|
+
|
|
54
|
+
from .biocore import (
|
|
55
|
+
_AA_DECODE,
|
|
56
|
+
_AA_DECODE_ARR,
|
|
57
|
+
_NUC_DECODE,
|
|
58
|
+
_NUC_DECODE_ARR,
|
|
59
|
+
AlignmentError,
|
|
60
|
+
BitPacker,
|
|
61
|
+
PackedSequence,
|
|
62
|
+
SeqType,
|
|
63
|
+
SequenceTypeError,
|
|
64
|
+
SequenceValueError,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
from .engine._loader import C_AVAILABLE
|
|
69
|
+
from .engine._loader import c_nw_align as _c_nw_align
|
|
70
|
+
from .engine._loader import c_nw_banded as _c_nw_banded
|
|
71
|
+
from .engine._loader import c_sw_align as _c_sw_align
|
|
72
|
+
except ImportError:
|
|
73
|
+
C_AVAILABLE = False
|
|
74
|
+
_c_nw_align = None # type: ignore[assignment]
|
|
75
|
+
_c_sw_align = None # type: ignore[assignment]
|
|
76
|
+
_c_nw_banded = None # type: ignore[assignment]
|
|
77
|
+
|
|
78
|
+
# Bytes de decodificación para el motor C (32 bytes: BioCode -> ASCII)
|
|
79
|
+
_NUC_DECODE_BYTES: bytes = bytes(_NUC_DECODE_ARR)
|
|
80
|
+
_AA_DECODE_BYTES: bytes = bytes(_AA_DECODE_ARR)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
__all__: list[str] = [
|
|
84
|
+
"Mutation",
|
|
85
|
+
"AlignmentResult",
|
|
86
|
+
"SequenceAligner",
|
|
87
|
+
"format_alignment",
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
92
|
+
# §1 DATA CLASSES
|
|
93
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Mutation:
|
|
97
|
+
"""
|
|
98
|
+
A single sequence variant between two aligned sequences.
|
|
99
|
+
|
|
100
|
+
Attributes
|
|
101
|
+
──────────
|
|
102
|
+
kind : 'substitution' | 'deletion' | 'insertion'
|
|
103
|
+
*deletion* — seq_a has a symbol; seq_b has a gap.
|
|
104
|
+
*insertion* — seq_a has a gap; seq_b has a symbol.
|
|
105
|
+
pos_a : 0-based index in seq_a (gap-insertion point for 'insertion').
|
|
106
|
+
pos_b : 0-based index in seq_b (gap-insertion point for 'deletion').
|
|
107
|
+
sym_a : symbol in seq_a ('-' for insertions).
|
|
108
|
+
sym_b : symbol in seq_b ('-' for deletions).
|
|
109
|
+
"""
|
|
110
|
+
kind: str
|
|
111
|
+
pos_a: int
|
|
112
|
+
pos_b: int
|
|
113
|
+
sym_a: str
|
|
114
|
+
sym_b: str
|
|
115
|
+
|
|
116
|
+
def __str__(self) -> str:
|
|
117
|
+
if self.kind == 'substitution':
|
|
118
|
+
return (
|
|
119
|
+
f"SUB a[{self.pos_a}]={self.sym_a!r} → "
|
|
120
|
+
f"b[{self.pos_b}]={self.sym_b!r}"
|
|
121
|
+
)
|
|
122
|
+
if self.kind == 'deletion':
|
|
123
|
+
return (
|
|
124
|
+
f"DEL a[{self.pos_a}]={self.sym_a!r} "
|
|
125
|
+
f"(deleción en seq_b, tras b[{self.pos_b}])"
|
|
126
|
+
)
|
|
127
|
+
return (
|
|
128
|
+
f"INS b[{self.pos_b}]={self.sym_b!r} "
|
|
129
|
+
f"(inserción en seq_b, gap en seq_a[{self.pos_a}])"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class AlignmentResult:
|
|
135
|
+
"""Full result of a pairwise Needleman-Wunsch alignment."""
|
|
136
|
+
score: int
|
|
137
|
+
identity: float # n_matches / aligned_length
|
|
138
|
+
n_matches: int
|
|
139
|
+
n_mismatches: int
|
|
140
|
+
n_gaps: int # total gap characters in the aligned region
|
|
141
|
+
mutations: list[Mutation]
|
|
142
|
+
aligned_a: str # seq_a string with '-' at gap positions
|
|
143
|
+
aligned_b: str # seq_b string with '-' at gap positions
|
|
144
|
+
seq_type: SeqType
|
|
145
|
+
mode: str # 'global' or 'semi-global'
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
149
|
+
# §2 SEQUENCE ALIGNER
|
|
150
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
151
|
+
|
|
152
|
+
class SequenceAligner:
|
|
153
|
+
"""
|
|
154
|
+
Needleman-Wunsch pairwise aligner with anti-diagonal (wavefront) strategy.
|
|
155
|
+
|
|
156
|
+
Scoring-matrix fill : O(m+n) Python iterations × NumPy inner ops.
|
|
157
|
+
Traceback : O(m+n) sequential Python loop (unavoidable).
|
|
158
|
+
|
|
159
|
+
Class constants
|
|
160
|
+
───────────────
|
|
161
|
+
MATCH : np.int32 +2
|
|
162
|
+
MISMATCH : np.int32 −1
|
|
163
|
+
GAP : np.int32 −2 (linear gap model)
|
|
164
|
+
_MAX_SAFE_LEN : int 15 000 (DP matrix stays ≤ ~3.4 GB int32)
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
MATCH: np.int32 = np.int32( 2)
|
|
168
|
+
MISMATCH: np.int32 = np.int32(-1)
|
|
169
|
+
GAP: np.int32 = np.int32(-2)
|
|
170
|
+
|
|
171
|
+
_MAX_SAFE_LEN: int = 15_000
|
|
172
|
+
|
|
173
|
+
# ── Public API ─────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def align(
|
|
177
|
+
cls,
|
|
178
|
+
seq_a: PackedSequence,
|
|
179
|
+
seq_b: PackedSequence,
|
|
180
|
+
mode: Literal['global', 'semi-global'] = 'global',
|
|
181
|
+
band: int | None = None,
|
|
182
|
+
) -> AlignmentResult:
|
|
183
|
+
"""
|
|
184
|
+
Align seq_a (reference) against seq_b (query) — Needleman-Wunsch.
|
|
185
|
+
|
|
186
|
+
Parameters
|
|
187
|
+
----------
|
|
188
|
+
seq_a : PackedSequence — reference.
|
|
189
|
+
seq_b : PackedSequence — query.
|
|
190
|
+
mode : 'global' — penalises all terminal gaps.
|
|
191
|
+
'semi-global' — free terminal gaps on the query side.
|
|
192
|
+
band : int, optional
|
|
193
|
+
Half-width of the alignment band. When given, only cells
|
|
194
|
+
with ``|i - j| ≤ band`` are computed (banded NW).
|
|
195
|
+
Reduces memory from O(m·n) to O(m·band) with the C engine.
|
|
196
|
+
Required when sequences exceed ``_MAX_SAFE_LEN``.
|
|
197
|
+
|
|
198
|
+
Returns
|
|
199
|
+
-------
|
|
200
|
+
AlignmentResult
|
|
201
|
+
|
|
202
|
+
Raises
|
|
203
|
+
------
|
|
204
|
+
TypeError — seq_a.seq_type ≠ seq_b.seq_type.
|
|
205
|
+
ValueError — either sequence is empty or band is too narrow.
|
|
206
|
+
"""
|
|
207
|
+
if not isinstance(seq_a, PackedSequence):
|
|
208
|
+
raise SequenceTypeError(
|
|
209
|
+
f"seq_a debe ser PackedSequence, se recibió {type(seq_a).__name__!r}. "
|
|
210
|
+
"Crea la secuencia con SmartImporter.from_string() o SmartImporter.from_file()."
|
|
211
|
+
)
|
|
212
|
+
if not isinstance(seq_b, PackedSequence):
|
|
213
|
+
raise SequenceTypeError(
|
|
214
|
+
f"seq_b debe ser PackedSequence, se recibió {type(seq_b).__name__!r}. "
|
|
215
|
+
"Crea la secuencia con SmartImporter.from_string() o SmartImporter.from_file()."
|
|
216
|
+
)
|
|
217
|
+
if mode not in ('global', 'semi-global'):
|
|
218
|
+
raise AlignmentError(
|
|
219
|
+
f"mode debe ser 'global' o 'semi-global', se recibió {mode!r}."
|
|
220
|
+
)
|
|
221
|
+
if seq_a.seq_type != seq_b.seq_type:
|
|
222
|
+
raise SequenceTypeError(
|
|
223
|
+
f"Los tipos no coinciden: "
|
|
224
|
+
f"{seq_a.seq_type.name} ≠ {seq_b.seq_type.name}. "
|
|
225
|
+
"Ambas secuencias deben ser del mismo tipo (NUCLEOTIDE o PROTEIN)."
|
|
226
|
+
)
|
|
227
|
+
if seq_a.n_symbols == 0 or seq_b.n_symbols == 0:
|
|
228
|
+
raise SequenceValueError("Las secuencias no pueden estar vacías.")
|
|
229
|
+
|
|
230
|
+
m, n = seq_a.n_symbols, seq_b.n_symbols
|
|
231
|
+
|
|
232
|
+
codes_a = seq_a.decode() # (m,) uint8
|
|
233
|
+
codes_b = seq_b.decode() # (n,) uint8
|
|
234
|
+
|
|
235
|
+
# ── Banded NW path ─────────────────────────────────────────────────────
|
|
236
|
+
if band is not None:
|
|
237
|
+
if band < 1:
|
|
238
|
+
raise AlignmentError(f"band debe ser ≥ 1, se recibió {band}.")
|
|
239
|
+
if C_AVAILABLE:
|
|
240
|
+
return cls._align_banded_c(
|
|
241
|
+
codes_a, codes_b, m, n, band, mode, seq_a.seq_type
|
|
242
|
+
)
|
|
243
|
+
# NumPy fallback: banded fill sobre matriz completa
|
|
244
|
+
if m > cls._MAX_SAFE_LEN or n > cls._MAX_SAFE_LEN:
|
|
245
|
+
raise AlignmentError(
|
|
246
|
+
f"Secuencias > {cls._MAX_SAFE_LEN:,} síms con band= requieren "
|
|
247
|
+
"el motor C. Compila con: python bioforge/engine/build.py"
|
|
248
|
+
)
|
|
249
|
+
H = cls._fill_matrix_banded(codes_a, codes_b, m, n, band, mode)
|
|
250
|
+
return cls._traceback(H, codes_a, codes_b, m, n, mode, seq_a.seq_type)
|
|
251
|
+
|
|
252
|
+
# ── NW completo ────────────────────────────────────────────────────────
|
|
253
|
+
if m > cls._MAX_SAFE_LEN or n > cls._MAX_SAFE_LEN:
|
|
254
|
+
mem_mb = (m + 1) * (n + 1) * 4 / 1_000_000
|
|
255
|
+
warnings.warn(
|
|
256
|
+
f"Secuencia larga ({max(m, n):,} síms → "
|
|
257
|
+
f"matriz DP ≈ {mem_mb:.0f} MB). "
|
|
258
|
+
f"Usa band=<valor> para alineamiento por bandas.",
|
|
259
|
+
UserWarning,
|
|
260
|
+
stacklevel=2,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if C_AVAILABLE:
|
|
264
|
+
return cls._align_c(codes_a, codes_b, m, n, mode, seq_a.seq_type)
|
|
265
|
+
|
|
266
|
+
H = cls._fill_matrix(codes_a, codes_b, m, n, mode)
|
|
267
|
+
return cls._traceback(H, codes_a, codes_b, m, n, mode, seq_a.seq_type)
|
|
268
|
+
|
|
269
|
+
@classmethod
|
|
270
|
+
def align_local(
|
|
271
|
+
cls,
|
|
272
|
+
seq_a: PackedSequence,
|
|
273
|
+
seq_b: PackedSequence,
|
|
274
|
+
) -> AlignmentResult:
|
|
275
|
+
"""
|
|
276
|
+
Smith-Waterman local alignment — finds the best-scoring subsequence.
|
|
277
|
+
|
|
278
|
+
Unlike ``align()`` (global/semi-global NW), local alignment:
|
|
279
|
+
• Does not penalise unaligned ends.
|
|
280
|
+
• Finds the highest-scoring contiguous region.
|
|
281
|
+
• Returns ``mode='local'`` in the result.
|
|
282
|
+
|
|
283
|
+
Use when searching for a short motif inside a longer sequence,
|
|
284
|
+
or when sequences share only a conserved domain.
|
|
285
|
+
|
|
286
|
+
Parameters
|
|
287
|
+
----------
|
|
288
|
+
seq_a : PackedSequence — reference (or the longer sequence).
|
|
289
|
+
seq_b : PackedSequence — query (or the motif to search for).
|
|
290
|
+
|
|
291
|
+
Returns
|
|
292
|
+
-------
|
|
293
|
+
AlignmentResult with ``mode='local'``.
|
|
294
|
+
|
|
295
|
+
Raises
|
|
296
|
+
------
|
|
297
|
+
SequenceTypeError — seq_a.seq_type ≠ seq_b.seq_type.
|
|
298
|
+
SequenceValueError — either sequence is empty.
|
|
299
|
+
"""
|
|
300
|
+
if not isinstance(seq_a, PackedSequence):
|
|
301
|
+
raise SequenceTypeError(
|
|
302
|
+
f"seq_a debe ser PackedSequence, se recibió {type(seq_a).__name__!r}."
|
|
303
|
+
)
|
|
304
|
+
if not isinstance(seq_b, PackedSequence):
|
|
305
|
+
raise SequenceTypeError(
|
|
306
|
+
f"seq_b debe ser PackedSequence, se recibió {type(seq_b).__name__!r}."
|
|
307
|
+
)
|
|
308
|
+
if seq_a.seq_type != seq_b.seq_type:
|
|
309
|
+
raise SequenceTypeError(
|
|
310
|
+
f"Los tipos no coinciden: "
|
|
311
|
+
f"{seq_a.seq_type.name} ≠ {seq_b.seq_type.name}."
|
|
312
|
+
)
|
|
313
|
+
if seq_a.n_symbols == 0 or seq_b.n_symbols == 0:
|
|
314
|
+
raise SequenceValueError("Las secuencias no pueden estar vacías.")
|
|
315
|
+
|
|
316
|
+
m, n = seq_a.n_symbols, seq_b.n_symbols
|
|
317
|
+
codes_a = seq_a.decode()
|
|
318
|
+
codes_b = seq_b.decode()
|
|
319
|
+
|
|
320
|
+
if C_AVAILABLE:
|
|
321
|
+
return cls._align_sw_c(codes_a, codes_b, m, n, seq_a.seq_type)
|
|
322
|
+
|
|
323
|
+
H = cls._fill_matrix_sw(codes_a, codes_b, m, n)
|
|
324
|
+
return cls._traceback_sw(H, codes_a, codes_b, m, n, seq_a.seq_type)
|
|
325
|
+
|
|
326
|
+
# ── Rutas C (motor nativo) ─────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
@classmethod
|
|
329
|
+
def _align_sw_c(
|
|
330
|
+
cls,
|
|
331
|
+
codes_a: np.ndarray,
|
|
332
|
+
codes_b: np.ndarray,
|
|
333
|
+
m: int,
|
|
334
|
+
n: int,
|
|
335
|
+
seq_type: SeqType,
|
|
336
|
+
) -> AlignmentResult:
|
|
337
|
+
decode_bytes = (
|
|
338
|
+
_NUC_DECODE_BYTES if seq_type == SeqType.NUCLEOTIDE else _AA_DECODE_BYTES
|
|
339
|
+
)
|
|
340
|
+
aligned_a, aligned_b, score, n_matches, n_mismatches, n_gaps = _c_sw_align(
|
|
341
|
+
codes_a, codes_b, decode_bytes,
|
|
342
|
+
int(cls.MATCH), int(cls.MISMATCH), int(cls.GAP),
|
|
343
|
+
)
|
|
344
|
+
mutations = cls._detect_mutations(aligned_a, aligned_b)
|
|
345
|
+
aln_len = n_matches + n_mismatches + n_gaps
|
|
346
|
+
identity = n_matches / aln_len if aln_len else 0.0
|
|
347
|
+
return AlignmentResult(
|
|
348
|
+
score=score, identity=identity,
|
|
349
|
+
n_matches=n_matches, n_mismatches=n_mismatches, n_gaps=n_gaps,
|
|
350
|
+
mutations=mutations, aligned_a=aligned_a, aligned_b=aligned_b,
|
|
351
|
+
seq_type=seq_type, mode='local',
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
@classmethod
|
|
355
|
+
def _align_banded_c(
|
|
356
|
+
cls,
|
|
357
|
+
codes_a: np.ndarray,
|
|
358
|
+
codes_b: np.ndarray,
|
|
359
|
+
m: int,
|
|
360
|
+
n: int,
|
|
361
|
+
band: int,
|
|
362
|
+
mode: str,
|
|
363
|
+
seq_type: SeqType,
|
|
364
|
+
) -> AlignmentResult:
|
|
365
|
+
decode_bytes = (
|
|
366
|
+
_NUC_DECODE_BYTES if seq_type == SeqType.NUCLEOTIDE else _AA_DECODE_BYTES
|
|
367
|
+
)
|
|
368
|
+
aligned_a, aligned_b, score, n_matches, n_mismatches, n_gaps = _c_nw_banded(
|
|
369
|
+
codes_a, codes_b, decode_bytes,
|
|
370
|
+
int(cls.MATCH), int(cls.MISMATCH), int(cls.GAP),
|
|
371
|
+
band, mode,
|
|
372
|
+
)
|
|
373
|
+
mutations = cls._detect_mutations(aligned_a, aligned_b)
|
|
374
|
+
aln_len = n_matches + n_mismatches + n_gaps
|
|
375
|
+
identity = n_matches / aln_len if aln_len else 0.0
|
|
376
|
+
return AlignmentResult(
|
|
377
|
+
score=score, identity=identity,
|
|
378
|
+
n_matches=n_matches, n_mismatches=n_mismatches, n_gaps=n_gaps,
|
|
379
|
+
mutations=mutations, aligned_a=aligned_a, aligned_b=aligned_b,
|
|
380
|
+
seq_type=seq_type, mode=mode,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
@classmethod
|
|
384
|
+
def _align_c(
|
|
385
|
+
cls,
|
|
386
|
+
codes_a: np.ndarray,
|
|
387
|
+
codes_b: np.ndarray,
|
|
388
|
+
m: int,
|
|
389
|
+
n: int,
|
|
390
|
+
mode: str,
|
|
391
|
+
seq_type: SeqType,
|
|
392
|
+
) -> AlignmentResult:
|
|
393
|
+
decode_bytes = (
|
|
394
|
+
_NUC_DECODE_BYTES if seq_type == SeqType.NUCLEOTIDE else _AA_DECODE_BYTES
|
|
395
|
+
)
|
|
396
|
+
aligned_a, aligned_b, score, n_matches, n_mismatches, n_gaps = _c_nw_align(
|
|
397
|
+
codes_a, codes_b, decode_bytes,
|
|
398
|
+
int(cls.MATCH), int(cls.MISMATCH), int(cls.GAP),
|
|
399
|
+
mode,
|
|
400
|
+
)
|
|
401
|
+
mutations = cls._detect_mutations(aligned_a, aligned_b)
|
|
402
|
+
aln_len = n_matches + n_mismatches + n_gaps
|
|
403
|
+
identity = n_matches / aln_len if aln_len else 0.0
|
|
404
|
+
return AlignmentResult(
|
|
405
|
+
score = score,
|
|
406
|
+
identity = identity,
|
|
407
|
+
n_matches = n_matches,
|
|
408
|
+
n_mismatches = n_mismatches,
|
|
409
|
+
n_gaps = n_gaps,
|
|
410
|
+
mutations = mutations,
|
|
411
|
+
aligned_a = aligned_a,
|
|
412
|
+
aligned_b = aligned_b,
|
|
413
|
+
seq_type = seq_type,
|
|
414
|
+
mode = mode,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
@staticmethod
|
|
418
|
+
def _detect_mutations(aligned_a: str, aligned_b: str) -> list[Mutation]:
|
|
419
|
+
mutations: list[Mutation] = []
|
|
420
|
+
pos_a = pos_b = 0
|
|
421
|
+
for sa, sb in zip(aligned_a, aligned_b, strict=True):
|
|
422
|
+
if sa == '-':
|
|
423
|
+
mutations.append(Mutation('insertion', pos_a, pos_b, '-', sb))
|
|
424
|
+
pos_b += 1
|
|
425
|
+
elif sb == '-':
|
|
426
|
+
mutations.append(Mutation('deletion', pos_a, pos_b, sa, '-'))
|
|
427
|
+
pos_a += 1
|
|
428
|
+
else:
|
|
429
|
+
if sa != sb:
|
|
430
|
+
mutations.append(Mutation('substitution', pos_a, pos_b, sa, sb))
|
|
431
|
+
pos_a += 1
|
|
432
|
+
pos_b += 1
|
|
433
|
+
return mutations
|
|
434
|
+
|
|
435
|
+
# ── Scoring matrix (anti-diagonal wavefront) ──────────────────────────────
|
|
436
|
+
|
|
437
|
+
@classmethod
|
|
438
|
+
def _fill_matrix(
|
|
439
|
+
cls,
|
|
440
|
+
codes_a: np.ndarray,
|
|
441
|
+
codes_b: np.ndarray,
|
|
442
|
+
m: int,
|
|
443
|
+
n: int,
|
|
444
|
+
mode: str,
|
|
445
|
+
) -> np.ndarray:
|
|
446
|
+
"""
|
|
447
|
+
Fill the (m+1)×(n+1) NW matrix using the anti-diagonal wavefront.
|
|
448
|
+
|
|
449
|
+
Anti-diagonal d = i + j. For each d ≥ 2 the computable cells
|
|
450
|
+
satisfy 1 ≤ i ≤ m and 1 ≤ j = d−i ≤ n, i.e.:
|
|
451
|
+
i ∈ [max(1, d−n) … min(m, d−1)]
|
|
452
|
+
|
|
453
|
+
All cells in one anti-diagonal are independent and are computed
|
|
454
|
+
in a single vectorised NumPy step.
|
|
455
|
+
|
|
456
|
+
Returns
|
|
457
|
+
-------
|
|
458
|
+
np.ndarray, dtype int32, shape (m+1, n+1)
|
|
459
|
+
"""
|
|
460
|
+
H = np.zeros((m + 1, n + 1), dtype=np.int32)
|
|
461
|
+
|
|
462
|
+
if mode == 'global':
|
|
463
|
+
H[0, :] = np.arange(n + 1, dtype=np.int32) * cls.GAP
|
|
464
|
+
H[:, 0] = np.arange(m + 1, dtype=np.int32) * cls.GAP
|
|
465
|
+
# semi-global: borders stay at 0 (free terminal gaps on the query)
|
|
466
|
+
|
|
467
|
+
# Pre-allocate index buffers once outside the loop.
|
|
468
|
+
# i_full[k] = k+1 → i_full[i_lo-1 : i_hi] is a zero-copy slice [i_lo…i_hi].
|
|
469
|
+
# j_buf receives d − i_arr in-place, eliminating one allocation per diagonal.
|
|
470
|
+
i_full = np.arange(1, max(m, n) + 1, dtype=np.int32)
|
|
471
|
+
j_buf = np.empty(min(m, n) + 1, dtype=np.int32)
|
|
472
|
+
|
|
473
|
+
for d in range(2, m + n + 1):
|
|
474
|
+
i_lo = max(1, d - n)
|
|
475
|
+
i_hi = min(m, d - 1) # j = d−i ≥ 1 ⟹ i ≤ d−1
|
|
476
|
+
if i_lo > i_hi:
|
|
477
|
+
continue
|
|
478
|
+
|
|
479
|
+
w = i_hi - i_lo + 1
|
|
480
|
+
i_arr = i_full[i_lo - 1: i_hi] # zero-copy slice — no allocation
|
|
481
|
+
np.subtract(d, i_arr, out=j_buf[:w]) # in-place: avoids j = d - i_arr copy
|
|
482
|
+
j_arr = j_buf[:w]
|
|
483
|
+
|
|
484
|
+
# ① Match/mismatch score per cell
|
|
485
|
+
match_mask = codes_a[i_arr - 1] == codes_b[j_arr - 1]
|
|
486
|
+
step = np.where(match_mask, cls.MATCH, cls.MISMATCH).astype(np.int32)
|
|
487
|
+
|
|
488
|
+
# ② Three candidate moves
|
|
489
|
+
diag = H[i_arr - 1, j_arr - 1] + step # ↖ match/mismatch
|
|
490
|
+
up = H[i_arr - 1, j_arr ] + cls.GAP # ↑ gap in seq_b
|
|
491
|
+
left = H[i_arr, j_arr - 1] + cls.GAP # ← gap in seq_a
|
|
492
|
+
|
|
493
|
+
# ③ Cell = maximum of three
|
|
494
|
+
H[i_arr, j_arr] = np.maximum(np.maximum(diag, up), left)
|
|
495
|
+
|
|
496
|
+
return H
|
|
497
|
+
|
|
498
|
+
@classmethod
|
|
499
|
+
def _fill_matrix_banded(
|
|
500
|
+
cls,
|
|
501
|
+
codes_a: np.ndarray,
|
|
502
|
+
codes_b: np.ndarray,
|
|
503
|
+
m: int,
|
|
504
|
+
n: int,
|
|
505
|
+
band: int,
|
|
506
|
+
mode: str,
|
|
507
|
+
) -> np.ndarray:
|
|
508
|
+
"""
|
|
509
|
+
Fill the NW matrix restricted to the band |i-j| ≤ band.
|
|
510
|
+
|
|
511
|
+
Out-of-band cells are initialised to NEG_INF so they can never
|
|
512
|
+
be chosen as optimal predecessors. The anti-diagonal wavefront
|
|
513
|
+
is clipped to the band intersection on each diagonal.
|
|
514
|
+
"""
|
|
515
|
+
NEG = np.int32(-10 ** 9)
|
|
516
|
+
H = np.full((m + 1, n + 1), NEG, dtype=np.int32)
|
|
517
|
+
|
|
518
|
+
H[0, 0] = np.int32(0)
|
|
519
|
+
for j in range(1, min(n, band) + 1):
|
|
520
|
+
H[0, j] = np.int32(0) if mode == 'semi-global' else np.int32(j) * cls.GAP
|
|
521
|
+
for i in range(1, min(m, band) + 1):
|
|
522
|
+
H[i, 0] = np.int32(i) * cls.GAP
|
|
523
|
+
|
|
524
|
+
i_full = np.arange(1, max(m, n) + 1, dtype=np.int32)
|
|
525
|
+
j_buf = np.empty(min(m, n) + 1, dtype=np.int32)
|
|
526
|
+
|
|
527
|
+
for d in range(2, m + n + 1):
|
|
528
|
+
i_lo = max(1, d - n, (d - band + 1) // 2)
|
|
529
|
+
i_hi = min(m, d - 1, (d + band) // 2)
|
|
530
|
+
if i_lo > i_hi:
|
|
531
|
+
continue
|
|
532
|
+
|
|
533
|
+
w = i_hi - i_lo + 1
|
|
534
|
+
i_arr = i_full[i_lo - 1: i_hi]
|
|
535
|
+
np.subtract(d, i_arr, out=j_buf[:w])
|
|
536
|
+
j_arr = j_buf[:w]
|
|
537
|
+
|
|
538
|
+
dv = H[i_arr - 1, j_arr - 1]
|
|
539
|
+
uv = H[i_arr - 1, j_arr]
|
|
540
|
+
lv = H[i_arr, j_arr - 1]
|
|
541
|
+
|
|
542
|
+
match_mask = codes_a[i_arr - 1] == codes_b[j_arr - 1]
|
|
543
|
+
step = np.where(match_mask, cls.MATCH, cls.MISMATCH).astype(np.int32)
|
|
544
|
+
|
|
545
|
+
d_score = np.where(dv != NEG, dv + step, NEG)
|
|
546
|
+
u_score = np.where(uv != NEG, uv + cls.GAP, NEG)
|
|
547
|
+
l_score = np.where(lv != NEG, lv + cls.GAP, NEG)
|
|
548
|
+
|
|
549
|
+
H[i_arr, j_arr] = np.maximum(np.maximum(d_score, u_score), l_score)
|
|
550
|
+
|
|
551
|
+
return H
|
|
552
|
+
|
|
553
|
+
@classmethod
|
|
554
|
+
def _fill_matrix_sw(
|
|
555
|
+
cls,
|
|
556
|
+
codes_a: np.ndarray,
|
|
557
|
+
codes_b: np.ndarray,
|
|
558
|
+
m: int,
|
|
559
|
+
n: int,
|
|
560
|
+
) -> np.ndarray:
|
|
561
|
+
"""
|
|
562
|
+
Fill the Smith-Waterman scoring matrix.
|
|
563
|
+
|
|
564
|
+
Identical to NW except cells are floored at 0.
|
|
565
|
+
Uses the same anti-diagonal wavefront strategy (O(m+n) iterations).
|
|
566
|
+
"""
|
|
567
|
+
H = np.zeros((m + 1, n + 1), dtype=np.int32)
|
|
568
|
+
# Borders stay 0 (SW boundary condition)
|
|
569
|
+
|
|
570
|
+
i_full = np.arange(1, max(m, n) + 1, dtype=np.int32)
|
|
571
|
+
j_buf = np.empty(min(m, n) + 1, dtype=np.int32)
|
|
572
|
+
|
|
573
|
+
for d in range(2, m + n + 1):
|
|
574
|
+
i_lo = max(1, d - n)
|
|
575
|
+
i_hi = min(m, d - 1)
|
|
576
|
+
if i_lo > i_hi:
|
|
577
|
+
continue
|
|
578
|
+
|
|
579
|
+
w = i_hi - i_lo + 1
|
|
580
|
+
i_arr = i_full[i_lo - 1: i_hi]
|
|
581
|
+
np.subtract(d, i_arr, out=j_buf[:w])
|
|
582
|
+
j_arr = j_buf[:w]
|
|
583
|
+
|
|
584
|
+
match_mask = codes_a[i_arr - 1] == codes_b[j_arr - 1]
|
|
585
|
+
step = np.where(match_mask, cls.MATCH, cls.MISMATCH).astype(np.int32)
|
|
586
|
+
|
|
587
|
+
diag = H[i_arr - 1, j_arr - 1] + step
|
|
588
|
+
up = H[i_arr - 1, j_arr ] + cls.GAP
|
|
589
|
+
left = H[i_arr, j_arr - 1] + cls.GAP
|
|
590
|
+
|
|
591
|
+
# SW: floor at 0
|
|
592
|
+
H[i_arr, j_arr] = np.maximum(
|
|
593
|
+
np.int32(0), np.maximum(np.maximum(diag, up), left)
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
return H
|
|
597
|
+
|
|
598
|
+
@classmethod
|
|
599
|
+
def _traceback_sw(
|
|
600
|
+
cls,
|
|
601
|
+
H: np.ndarray,
|
|
602
|
+
codes_a: np.ndarray,
|
|
603
|
+
codes_b: np.ndarray,
|
|
604
|
+
m: int,
|
|
605
|
+
n: int,
|
|
606
|
+
seq_type: SeqType,
|
|
607
|
+
) -> AlignmentResult:
|
|
608
|
+
"""Traceback for Smith-Waterman: start at max(H), stop at 0."""
|
|
609
|
+
decode = _NUC_DECODE if seq_type == SeqType.NUCLEOTIDE else _AA_DECODE
|
|
610
|
+
|
|
611
|
+
max_idx = np.argmax(H)
|
|
612
|
+
score = int(H.flat[max_idx])
|
|
613
|
+
start_i, start_j = divmod(int(max_idx), n + 1)
|
|
614
|
+
|
|
615
|
+
i, j = start_i, start_j
|
|
616
|
+
aln_a: list[str] = []
|
|
617
|
+
aln_b: list[str] = []
|
|
618
|
+
mutations: list[Mutation] = []
|
|
619
|
+
n_matches = n_mismatches = n_gaps = 0
|
|
620
|
+
|
|
621
|
+
while i > 0 and j > 0 and H[i, j] > 0:
|
|
622
|
+
step = cls.MATCH if codes_a[i-1] == codes_b[j-1] else cls.MISMATCH
|
|
623
|
+
|
|
624
|
+
if H[i, j] == H[i-1, j-1] + step:
|
|
625
|
+
sa = decode.get(int(codes_a[i-1]), '?')
|
|
626
|
+
sb = decode.get(int(codes_b[j-1]), '?')
|
|
627
|
+
aln_a.append(sa); aln_b.append(sb)
|
|
628
|
+
if codes_a[i-1] == codes_b[j-1]:
|
|
629
|
+
n_matches += 1
|
|
630
|
+
else:
|
|
631
|
+
n_mismatches += 1
|
|
632
|
+
mutations.append(Mutation('substitution', i - 1, j - 1, sa, sb))
|
|
633
|
+
i -= 1; j -= 1
|
|
634
|
+
|
|
635
|
+
elif H[i, j] == H[i-1, j] + cls.GAP:
|
|
636
|
+
sa = decode.get(int(codes_a[i-1]), '?')
|
|
637
|
+
aln_a.append(sa); aln_b.append('-')
|
|
638
|
+
n_gaps += 1
|
|
639
|
+
mutations.append(Mutation('deletion', i - 1, j, sa, '-'))
|
|
640
|
+
i -= 1
|
|
641
|
+
|
|
642
|
+
else:
|
|
643
|
+
sb = decode.get(int(codes_b[j-1]), '?')
|
|
644
|
+
aln_a.append('-'); aln_b.append(sb)
|
|
645
|
+
n_gaps += 1
|
|
646
|
+
mutations.append(Mutation('insertion', i, j - 1, '-', sb))
|
|
647
|
+
j -= 1
|
|
648
|
+
|
|
649
|
+
aln_a.reverse(); aln_b.reverse(); mutations.reverse()
|
|
650
|
+
aln_len = n_matches + n_mismatches + n_gaps
|
|
651
|
+
identity = n_matches / aln_len if aln_len else 0.0
|
|
652
|
+
|
|
653
|
+
return AlignmentResult(
|
|
654
|
+
score=score, identity=identity,
|
|
655
|
+
n_matches=n_matches, n_mismatches=n_mismatches, n_gaps=n_gaps,
|
|
656
|
+
mutations=mutations,
|
|
657
|
+
aligned_a=''.join(aln_a), aligned_b=''.join(aln_b),
|
|
658
|
+
seq_type=seq_type, mode='local',
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
# ── Traceback ─────────────────────────────────────────────────────────────
|
|
662
|
+
|
|
663
|
+
@classmethod
|
|
664
|
+
def _traceback(
|
|
665
|
+
cls,
|
|
666
|
+
H: np.ndarray,
|
|
667
|
+
codes_a: np.ndarray,
|
|
668
|
+
codes_b: np.ndarray,
|
|
669
|
+
m: int,
|
|
670
|
+
n: int,
|
|
671
|
+
mode: str,
|
|
672
|
+
seq_type: SeqType,
|
|
673
|
+
) -> AlignmentResult:
|
|
674
|
+
"""
|
|
675
|
+
Reconstruct the optimal alignment by walking back through H.
|
|
676
|
+
|
|
677
|
+
O(m+n) sequential loop — the step dependency makes vectorisation
|
|
678
|
+
impossible here. Each iteration is O(1) scalar work.
|
|
679
|
+
"""
|
|
680
|
+
decode = _NUC_DECODE if seq_type == SeqType.NUCLEOTIDE else _AA_DECODE
|
|
681
|
+
|
|
682
|
+
# ── Starting cell ──────────────────────────────────────────────────────
|
|
683
|
+
if mode == 'global':
|
|
684
|
+
i, j = m, n
|
|
685
|
+
else:
|
|
686
|
+
# Semi-global: pick the best score in the last row or last column.
|
|
687
|
+
j_best_row = int(np.argmax(H[m, :]))
|
|
688
|
+
i_best_col = int(np.argmax(H[:, n]))
|
|
689
|
+
if H[m, j_best_row] >= H[i_best_col, n]:
|
|
690
|
+
i, j = m, j_best_row
|
|
691
|
+
else:
|
|
692
|
+
i, j = i_best_col, n
|
|
693
|
+
|
|
694
|
+
score = int(H[i, j])
|
|
695
|
+
|
|
696
|
+
aln_a: list[str] = []
|
|
697
|
+
aln_b: list[str] = []
|
|
698
|
+
mutations: list[Mutation] = []
|
|
699
|
+
n_matches = n_mismatches = n_gaps = 0
|
|
700
|
+
|
|
701
|
+
# ── Traceback loop ─────────────────────────────────────────────────────
|
|
702
|
+
while i > 0 or j > 0:
|
|
703
|
+
|
|
704
|
+
if i > 0 and j > 0:
|
|
705
|
+
step = cls.MATCH if codes_a[i-1] == codes_b[j-1] else cls.MISMATCH
|
|
706
|
+
|
|
707
|
+
if H[i, j] == H[i-1, j-1] + step:
|
|
708
|
+
# Diagonal — match or mismatch
|
|
709
|
+
sa = decode.get(int(codes_a[i-1]), '?')
|
|
710
|
+
sb = decode.get(int(codes_b[j-1]), '?')
|
|
711
|
+
aln_a.append(sa)
|
|
712
|
+
aln_b.append(sb)
|
|
713
|
+
if codes_a[i-1] == codes_b[j-1]:
|
|
714
|
+
n_matches += 1
|
|
715
|
+
else:
|
|
716
|
+
n_mismatches += 1
|
|
717
|
+
mutations.append(
|
|
718
|
+
Mutation('substitution', i - 1, j - 1, sa, sb)
|
|
719
|
+
)
|
|
720
|
+
i -= 1; j -= 1
|
|
721
|
+
|
|
722
|
+
elif H[i, j] == H[i-1, j] + cls.GAP:
|
|
723
|
+
# Up — gap in seq_b (deletion in query vs reference)
|
|
724
|
+
sa = decode.get(int(codes_a[i-1]), '?')
|
|
725
|
+
aln_a.append(sa)
|
|
726
|
+
aln_b.append('-')
|
|
727
|
+
n_gaps += 1
|
|
728
|
+
mutations.append(Mutation('deletion', i - 1, j, sa, '-'))
|
|
729
|
+
i -= 1
|
|
730
|
+
|
|
731
|
+
else:
|
|
732
|
+
# Left — gap in seq_a (insertion in query vs reference)
|
|
733
|
+
sb = decode.get(int(codes_b[j-1]), '?')
|
|
734
|
+
aln_a.append('-')
|
|
735
|
+
aln_b.append(sb)
|
|
736
|
+
n_gaps += 1
|
|
737
|
+
mutations.append(Mutation('insertion', i, j - 1, '-', sb))
|
|
738
|
+
j -= 1
|
|
739
|
+
|
|
740
|
+
elif i > 0:
|
|
741
|
+
# Terminal: remaining seq_a consumed as gap in seq_b
|
|
742
|
+
sa = decode.get(int(codes_a[i-1]), '?')
|
|
743
|
+
aln_a.append(sa)
|
|
744
|
+
aln_b.append('-')
|
|
745
|
+
if mode == 'global':
|
|
746
|
+
n_gaps += 1
|
|
747
|
+
mutations.append(Mutation('deletion', i - 1, 0, sa, '-'))
|
|
748
|
+
i -= 1
|
|
749
|
+
|
|
750
|
+
else:
|
|
751
|
+
# Terminal: remaining seq_b consumed as gap in seq_a
|
|
752
|
+
sb = decode.get(int(codes_b[j-1]), '?')
|
|
753
|
+
aln_a.append('-')
|
|
754
|
+
aln_b.append(sb)
|
|
755
|
+
if mode == 'global':
|
|
756
|
+
n_gaps += 1
|
|
757
|
+
mutations.append(Mutation('insertion', 0, j - 1, '-', sb))
|
|
758
|
+
j -= 1
|
|
759
|
+
|
|
760
|
+
# Traceback builds alignment end→start; reverse to restore order.
|
|
761
|
+
aln_a.reverse()
|
|
762
|
+
aln_b.reverse()
|
|
763
|
+
mutations.reverse()
|
|
764
|
+
|
|
765
|
+
aln_len = n_matches + n_mismatches + n_gaps
|
|
766
|
+
identity = n_matches / aln_len if aln_len else 0.0
|
|
767
|
+
|
|
768
|
+
return AlignmentResult(
|
|
769
|
+
score = score,
|
|
770
|
+
identity = identity,
|
|
771
|
+
n_matches = n_matches,
|
|
772
|
+
n_mismatches = n_mismatches,
|
|
773
|
+
n_gaps = n_gaps,
|
|
774
|
+
mutations = mutations,
|
|
775
|
+
aligned_a = ''.join(aln_a),
|
|
776
|
+
aligned_b = ''.join(aln_b),
|
|
777
|
+
seq_type = seq_type,
|
|
778
|
+
mode = mode,
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
783
|
+
# §3 UTILITIES
|
|
784
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
785
|
+
|
|
786
|
+
def format_alignment(result: AlignmentResult, width: int = 60) -> str:
|
|
787
|
+
"""
|
|
788
|
+
Return a human-readable block alignment string.
|
|
789
|
+
|
|
790
|
+
Symbol legend: '|' match · 'X' mismatch · ' ' gap.
|
|
791
|
+
Vectorised: builds the midline with NumPy, no Python character loop.
|
|
792
|
+
"""
|
|
793
|
+
if width <= 0:
|
|
794
|
+
raise AlignmentError(f"width debe ser > 0, se recibió {width}.")
|
|
795
|
+
a, b = result.aligned_a, result.aligned_b
|
|
796
|
+
if len(a) != len(b):
|
|
797
|
+
raise AlignmentError(
|
|
798
|
+
f"aligned_a y aligned_b tienen longitudes distintas: {len(a)} ≠ {len(b)}."
|
|
799
|
+
)
|
|
800
|
+
a_arr = np.frombuffer(a.encode(), dtype=np.uint8)
|
|
801
|
+
b_arr = np.frombuffer(b.encode(), dtype=np.uint8)
|
|
802
|
+
gap = np.uint8(ord('-'))
|
|
803
|
+
mid = np.full(len(a_arr), ord(' '), dtype=np.uint8)
|
|
804
|
+
match = (a_arr == b_arr) & (a_arr != gap)
|
|
805
|
+
mism = (a_arr != b_arr) & (a_arr != gap) & (b_arr != gap)
|
|
806
|
+
mid[match] = ord('|')
|
|
807
|
+
mid[mism] = ord('X')
|
|
808
|
+
midline = mid.tobytes().decode('ascii')
|
|
809
|
+
lines: list[str] = []
|
|
810
|
+
for s in range(0, len(a), width):
|
|
811
|
+
e = s + width
|
|
812
|
+
lines += [
|
|
813
|
+
f" A: {a[s:e]}",
|
|
814
|
+
f" {midline[s:e]}",
|
|
815
|
+
f" B: {b[s:e]}",
|
|
816
|
+
"",
|
|
817
|
+
]
|
|
818
|
+
return '\n'.join(lines)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
822
|
+
# §4 DEMO / SELF-TEST (python aligner.py)
|
|
823
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
824
|
+
|
|
825
|
+
if __name__ == "__main__":
|
|
826
|
+
import sys
|
|
827
|
+
import time
|
|
828
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
829
|
+
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
830
|
+
from bioforge import SmartImporter
|
|
831
|
+
|
|
832
|
+
W = 65
|
|
833
|
+
print("═" * W)
|
|
834
|
+
print(" aligner.py — Needleman-Wunsch anti-diagonal wavefront demo")
|
|
835
|
+
print("═" * W)
|
|
836
|
+
|
|
837
|
+
# ── Test 1: secuencias idénticas (sanity check) ────────────────────────
|
|
838
|
+
print("\n ── Test 1: secuencias idénticas " + "─" * 32)
|
|
839
|
+
_SEQ = "ATGGTGCACCTGACTCCTGAGGAGAAGTCTGCCGTTACTGCCCTGTGGGG"
|
|
840
|
+
_FASTA = f">a\n{_SEQ}\n>b\n{_SEQ}\n"
|
|
841
|
+
_seqs = SmartImporter.from_string(_FASTA, force_type=SeqType.NUCLEOTIDE)
|
|
842
|
+
r1 = SequenceAligner.align(_seqs[0], _seqs[1])
|
|
843
|
+
assert r1.n_mismatches == 0 and r1.n_gaps == 0, "❌ deberían ser idénticas"
|
|
844
|
+
assert r1.identity == 1.0
|
|
845
|
+
assert r1.score == len(_SEQ) * int(SequenceAligner.MATCH)
|
|
846
|
+
print(f" Score={r1.score} Identity={r1.identity:.0%} "
|
|
847
|
+
f"Mutaciones={len(r1.mutations)} ✅")
|
|
848
|
+
|
|
849
|
+
# ── Test 2: HBB normal vs sickle cell (única sustitución A→T, pos 19) ──
|
|
850
|
+
print("\n ── Test 2: HBB normal vs sickle cell (A19T) " + "─" * 20)
|
|
851
|
+
_NORMAL = ">HBB_normal\nATGGTGCACCTGACTCCTGAGGAGAAGTCT\n"
|
|
852
|
+
_SICKLE = ">HBB_sickle\nATGGTGCACCTGACTCCTGTGGAGAAGTCT\n"
|
|
853
|
+
_n = SmartImporter.from_string(_NORMAL, force_type=SeqType.NUCLEOTIDE)[0]
|
|
854
|
+
_s = SmartImporter.from_string(_SICKLE, force_type=SeqType.NUCLEOTIDE)[0]
|
|
855
|
+
r2 = SequenceAligner.align(_n, _s)
|
|
856
|
+
print(f" Score={r2.score} Identity={r2.identity:.1%} "
|
|
857
|
+
f"Mismatches={r2.n_mismatches} Gaps={r2.n_gaps}")
|
|
858
|
+
print(format_alignment(r2))
|
|
859
|
+
assert len(r2.mutations) == 1
|
|
860
|
+
assert r2.mutations[0].kind == 'substitution'
|
|
861
|
+
assert r2.mutations[0].pos_a == 19
|
|
862
|
+
assert r2.mutations[0].sym_a == 'A' and r2.mutations[0].sym_b == 'T'
|
|
863
|
+
print(f" Mutación detectada: {r2.mutations[0]} ✅")
|
|
864
|
+
|
|
865
|
+
# ── Test 3: inserción de 3 bases (codón extra) ─────────────────────────
|
|
866
|
+
print("\n ── Test 3: inserción de 3 bases " + "─" * 33)
|
|
867
|
+
_REF = ">ref\nATGGTGCACCTGACTGAA\n" # 18 nt
|
|
868
|
+
_INS = ">ins\nATGGTGCACCTGACTCCCGAA\n" # 21 nt (CCC en pos 15)
|
|
869
|
+
_r = SmartImporter.from_string(_REF, force_type=SeqType.NUCLEOTIDE)[0]
|
|
870
|
+
_i = SmartImporter.from_string(_INS, force_type=SeqType.NUCLEOTIDE)[0]
|
|
871
|
+
r3 = SequenceAligner.align(_r, _i)
|
|
872
|
+
print(f" Score={r3.score} Identity={r3.identity:.1%} "
|
|
873
|
+
f"Mismatches={r3.n_mismatches} Gaps={r3.n_gaps}")
|
|
874
|
+
print(format_alignment(r3))
|
|
875
|
+
_ins_muts = [m for m in r3.mutations if m.kind == 'insertion']
|
|
876
|
+
print(f" Inserciones detectadas: {len(_ins_muts)} base(s) "
|
|
877
|
+
f"{'✅' if _ins_muts else '⚠ (ninguna)'}")
|
|
878
|
+
|
|
879
|
+
# ── Test 4: alineamiento de proteínas (HBB α vs β) ────────────────────
|
|
880
|
+
print("\n ── Test 4: proteínas HBB-alpha vs HBB-beta " + "─" * 21)
|
|
881
|
+
_HBB = ">HBB\nMVHLTPEEKSAVTALWGKVNVDEVGGEALGRLLVVYPWTQRFFESFGDLST\n"
|
|
882
|
+
_HBA = ">HBA\nMVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLS\n"
|
|
883
|
+
_hbb = SmartImporter.from_string(_HBB, force_type=SeqType.PROTEIN)[0]
|
|
884
|
+
_hba = SmartImporter.from_string(_HBA, force_type=SeqType.PROTEIN)[0]
|
|
885
|
+
r4 = SequenceAligner.align(_hbb, _hba)
|
|
886
|
+
print(f" Score={r4.score} Identity={r4.identity:.1%} "
|
|
887
|
+
f"Matches={r4.n_matches} Mismatches={r4.n_mismatches} "
|
|
888
|
+
f"Gaps={r4.n_gaps}")
|
|
889
|
+
print(format_alignment(r4))
|
|
890
|
+
print(f" Mutaciones totales: {len(r4.mutations)}")
|
|
891
|
+
for _m in r4.mutations[:5]:
|
|
892
|
+
print(f" {_m}")
|
|
893
|
+
if len(r4.mutations) > 5:
|
|
894
|
+
print(f" … y {len(r4.mutations) - 5} más")
|
|
895
|
+
|
|
896
|
+
# ── Test 5: rutas de error ─────────────────────────────────────────────
|
|
897
|
+
print("\n ── Test 5: rutas de error " + "─" * 39)
|
|
898
|
+
_prot = PackedSequence(
|
|
899
|
+
header="p", seq_type=SeqType.PROTEIN, n_symbols=4,
|
|
900
|
+
data=BitPacker.pack(np.array([4, 5, 6, 7], dtype=np.uint8)),
|
|
901
|
+
)
|
|
902
|
+
_nuc = SmartImporter.from_string(">n\nACGT\n",
|
|
903
|
+
force_type=SeqType.NUCLEOTIDE)[0]
|
|
904
|
+
try:
|
|
905
|
+
SequenceAligner.align(_prot, _nuc)
|
|
906
|
+
print(" TypeError no lanzado ❌")
|
|
907
|
+
except TypeError as exc:
|
|
908
|
+
print(f" TypeError ✅ → {exc}")
|
|
909
|
+
|
|
910
|
+
_empty = PackedSequence(
|
|
911
|
+
header="e", seq_type=SeqType.NUCLEOTIDE, n_symbols=1,
|
|
912
|
+
data=BitPacker.pack(np.array([0], dtype=np.uint8)),
|
|
913
|
+
)
|
|
914
|
+
_empty2 = PackedSequence(
|
|
915
|
+
header="e2", seq_type=SeqType.NUCLEOTIDE, n_symbols=1,
|
|
916
|
+
data=BitPacker.pack(np.array([0], dtype=np.uint8)),
|
|
917
|
+
)
|
|
918
|
+
r_tiny = SequenceAligner.align(_empty, _empty2)
|
|
919
|
+
assert r_tiny.score == int(SequenceAligner.MATCH)
|
|
920
|
+
print(f" Alineamiento 1×1 (A vs A): score={r_tiny.score} ✅")
|
|
921
|
+
|
|
922
|
+
# ── Test 6: benchmark — 1 000 × 1 000 nt con 1 % mutaciones ───────────
|
|
923
|
+
print("\n ── Test 6: benchmark 1 000 × 1 000 nt (1 % mutaciones) " + "─" * 8)
|
|
924
|
+
_rng = np.random.default_rng(42)
|
|
925
|
+
_bases = np.array([0, 1, 2, 3], dtype=np.uint8)
|
|
926
|
+
_ca = _rng.choice(_bases, size=1000)
|
|
927
|
+
_cb = _ca.copy()
|
|
928
|
+
_mutpos = _rng.choice(1000, size=10, replace=False)
|
|
929
|
+
for _p in _mutpos:
|
|
930
|
+
_cb[_p] = (_cb[_p] + 1) % 4 # cyclic 1-step substitution
|
|
931
|
+
|
|
932
|
+
_ps_a = PackedSequence(
|
|
933
|
+
header="bench_a", seq_type=SeqType.NUCLEOTIDE,
|
|
934
|
+
n_symbols=1000, data=BitPacker.pack(_ca),
|
|
935
|
+
)
|
|
936
|
+
_ps_b = PackedSequence(
|
|
937
|
+
header="bench_b", seq_type=SeqType.NUCLEOTIDE,
|
|
938
|
+
n_symbols=1000, data=BitPacker.pack(_cb),
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
_t0 = time.perf_counter()
|
|
942
|
+
_rb = SequenceAligner.align(_ps_a, _ps_b)
|
|
943
|
+
_t1 = time.perf_counter()
|
|
944
|
+
|
|
945
|
+
_elapsed_ms = (_t1 - _t0) * 1e3
|
|
946
|
+
print(f" Tiempo : {_elapsed_ms:.1f} ms")
|
|
947
|
+
print(f" Score : {_rb.score}")
|
|
948
|
+
print(f" Identity : {_rb.identity:.2%}")
|
|
949
|
+
print(f" Mutaciones : {len(_rb.mutations)} (esperadas ≈ 10)")
|
|
950
|
+
assert abs(len(_rb.mutations) - 10) <= 2, "Mutaciones fuera de rango esperado"
|
|
951
|
+
print(" ✅ Benchmark superado")
|
|
952
|
+
|
|
953
|
+
print(f"\n{'═' * W}\n")
|