bioforge 2.3.0__tar.gz → 3.0.0__tar.gz
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 → bioforge-3.0.0}/PKG-INFO +44 -1
- {bioforge-2.3.0 → bioforge-3.0.0}/README.md +43 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/__init__.py +5 -1
- {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/engine/_loader.py +40 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/engine/engine.c +43 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/engine/engine.dll +0 -0
- bioforge-3.0.0/bioforge/genomemap.py +364 -0
- bioforge-3.0.0/bioforge/minimizers.py +148 -0
- bioforge-3.0.0/bioforge/refindex.py +111 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/bioforge.egg-info/PKG-INFO +44 -1
- {bioforge-2.3.0 → bioforge-3.0.0}/bioforge.egg-info/SOURCES.txt +6 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/pyproject.toml +7 -3
- bioforge-3.0.0/tests/test_genomemap.py +144 -0
- bioforge-3.0.0/tests/test_minimizers.py +133 -0
- bioforge-3.0.0/tests/test_refindex.py +98 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/LICENSE +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/aligner.py +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/analyze.py +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/bgzf.py +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/biocore.py +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/engine/__init__.py +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/engine/build.py +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/qcreport.py +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/smart_translator.py +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/bioforge.egg-info/dependency_links.txt +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/bioforge.egg-info/entry_points.txt +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/bioforge.egg-info/requires.txt +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/bioforge.egg-info/top_level.txt +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/setup.cfg +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/setup.py +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/tests/test_aligner.py +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/tests/test_analyze.py +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/tests/test_bgzf.py +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/tests/test_biocore.py +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/tests/test_errors.py +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/tests/test_qcreport.py +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/tests/test_streaming.py +0 -0
- {bioforge-2.3.0 → bioforge-3.0.0}/tests/test_translator.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bioforge
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: High-performance bioinformatics engine for Edge Computing — 5-bit encoding, vectorised NumPy core, optional C backend
|
|
5
5
|
Author-email: Aarón Aranda Torrijos <noe9601@gmail.com>
|
|
6
6
|
License: # PolyForm Noncommercial License 1.0.0
|
|
@@ -554,6 +554,26 @@ for mut in result.mutations:
|
|
|
554
554
|
# Mutation(kind='substitution', pos_a=18, pos_b=18, sym_a='A', sym_b='T')
|
|
555
555
|
```
|
|
556
556
|
|
|
557
|
+
### Map long reads to a genome (Level 4 — seed-chain-align)
|
|
558
|
+
|
|
559
|
+
Locate reads in a reference far beyond what the O(m·n) aligner can handle,
|
|
560
|
+
minimap2-style: minimizer seeding → chaining (C DP) → banded extension.
|
|
561
|
+
|
|
562
|
+
```python
|
|
563
|
+
from bioforge import GenomeAligner
|
|
564
|
+
|
|
565
|
+
mapper = GenomeAligner(reference_sequence, k=15, w=10) # builds the index once
|
|
566
|
+
|
|
567
|
+
for m in mapper.map(read):
|
|
568
|
+
print(m.to_paf()) # standard PAF, one line per mapping
|
|
569
|
+
# read1 1000 0 1000 + chr1 4600000 733120 734118 980 998 60 ...
|
|
570
|
+
print(m.strand, m.target_start, f"{m.identity:.1%}")
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
Handles both strands, tolerates mismatches/indels, and reports a mapping
|
|
574
|
+
quality. The chaining dynamic program runs in the C engine (with a NumPy
|
|
575
|
+
fallback).
|
|
576
|
+
|
|
557
577
|
### Full mutation analysis pipeline (DNA + protein)
|
|
558
578
|
|
|
559
579
|
```python
|
|
@@ -722,6 +742,29 @@ python check.py
|
|
|
722
742
|
|
|
723
743
|
---
|
|
724
744
|
|
|
745
|
+
## References & inspiration
|
|
746
|
+
|
|
747
|
+
BioForge's genome mapper (Level 4) is an **independent, from-scratch
|
|
748
|
+
implementation** of well-established, published algorithms. No third-party
|
|
749
|
+
source code is included or copied — only the *ideas* from the scientific
|
|
750
|
+
literature, which is what publishing them is for. With gratitude to:
|
|
751
|
+
|
|
752
|
+
- **Minimap2** — Li, H. (2018). *Minimap2: pairwise alignment for nucleotide
|
|
753
|
+
sequences.* Bioinformatics, 34(18), 3094–3100. The seed-chain-align strategy
|
|
754
|
+
and the chaining dynamic program that inspired `genomemap.py`.
|
|
755
|
+
([paper](https://doi.org/10.1093/bioinformatics/bty191) ·
|
|
756
|
+
[MIT-licensed source](https://github.com/lh3/minimap2))
|
|
757
|
+
- **Minimizers** — Roberts, M., Hayes, W., Hunt, B. R., Mount, S. M., &
|
|
758
|
+
Yorke, J. A. (2004). *Reducing storage requirements for biological sequence
|
|
759
|
+
comparison.* Bioinformatics, 20(18), 3363–3369. The (w, k) minimizer sampling
|
|
760
|
+
behind `minimizers.py`.
|
|
761
|
+
- **Needleman–Wunsch** (1970) and **Smith–Waterman** (1981) — the classic
|
|
762
|
+
dynamic-programming alignments behind Level 3.
|
|
763
|
+
|
|
764
|
+
BioForge is not affiliated with or endorsed by the authors of the above.
|
|
765
|
+
|
|
766
|
+
---
|
|
767
|
+
|
|
725
768
|
## Author
|
|
726
769
|
|
|
727
770
|
**Aarón Aranda Torrijos** — [github.com/erlanders177](https://github.com/erlanders177)
|
|
@@ -234,6 +234,26 @@ for mut in result.mutations:
|
|
|
234
234
|
# Mutation(kind='substitution', pos_a=18, pos_b=18, sym_a='A', sym_b='T')
|
|
235
235
|
```
|
|
236
236
|
|
|
237
|
+
### Map long reads to a genome (Level 4 — seed-chain-align)
|
|
238
|
+
|
|
239
|
+
Locate reads in a reference far beyond what the O(m·n) aligner can handle,
|
|
240
|
+
minimap2-style: minimizer seeding → chaining (C DP) → banded extension.
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
from bioforge import GenomeAligner
|
|
244
|
+
|
|
245
|
+
mapper = GenomeAligner(reference_sequence, k=15, w=10) # builds the index once
|
|
246
|
+
|
|
247
|
+
for m in mapper.map(read):
|
|
248
|
+
print(m.to_paf()) # standard PAF, one line per mapping
|
|
249
|
+
# read1 1000 0 1000 + chr1 4600000 733120 734118 980 998 60 ...
|
|
250
|
+
print(m.strand, m.target_start, f"{m.identity:.1%}")
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Handles both strands, tolerates mismatches/indels, and reports a mapping
|
|
254
|
+
quality. The chaining dynamic program runs in the C engine (with a NumPy
|
|
255
|
+
fallback).
|
|
256
|
+
|
|
237
257
|
### Full mutation analysis pipeline (DNA + protein)
|
|
238
258
|
|
|
239
259
|
```python
|
|
@@ -402,6 +422,29 @@ python check.py
|
|
|
402
422
|
|
|
403
423
|
---
|
|
404
424
|
|
|
425
|
+
## References & inspiration
|
|
426
|
+
|
|
427
|
+
BioForge's genome mapper (Level 4) is an **independent, from-scratch
|
|
428
|
+
implementation** of well-established, published algorithms. No third-party
|
|
429
|
+
source code is included or copied — only the *ideas* from the scientific
|
|
430
|
+
literature, which is what publishing them is for. With gratitude to:
|
|
431
|
+
|
|
432
|
+
- **Minimap2** — Li, H. (2018). *Minimap2: pairwise alignment for nucleotide
|
|
433
|
+
sequences.* Bioinformatics, 34(18), 3094–3100. The seed-chain-align strategy
|
|
434
|
+
and the chaining dynamic program that inspired `genomemap.py`.
|
|
435
|
+
([paper](https://doi.org/10.1093/bioinformatics/bty191) ·
|
|
436
|
+
[MIT-licensed source](https://github.com/lh3/minimap2))
|
|
437
|
+
- **Minimizers** — Roberts, M., Hayes, W., Hunt, B. R., Mount, S. M., &
|
|
438
|
+
Yorke, J. A. (2004). *Reducing storage requirements for biological sequence
|
|
439
|
+
comparison.* Bioinformatics, 20(18), 3363–3369. The (w, k) minimizer sampling
|
|
440
|
+
behind `minimizers.py`.
|
|
441
|
+
- **Needleman–Wunsch** (1970) and **Smith–Waterman** (1981) — the classic
|
|
442
|
+
dynamic-programming alignments behind Level 3.
|
|
443
|
+
|
|
444
|
+
BioForge is not affiliated with or endorsed by the authors of the above.
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
405
448
|
## Author
|
|
406
449
|
|
|
407
450
|
**Aarón Aranda Torrijos** — [github.com/erlanders177](https://github.com/erlanders177)
|
|
@@ -33,9 +33,10 @@ from .biocore import (
|
|
|
33
33
|
TranslationError,
|
|
34
34
|
compute_stats,
|
|
35
35
|
)
|
|
36
|
+
from .genomemap import GenomeAligner, Mapping
|
|
36
37
|
from .smart_translator import SmartTranslator
|
|
37
38
|
|
|
38
|
-
__version__ = "
|
|
39
|
+
__version__ = "3.0.0"
|
|
39
40
|
__author__ = "Aarón Aranda Torrijos"
|
|
40
41
|
|
|
41
42
|
__all__ = [
|
|
@@ -67,6 +68,9 @@ __all__ = [
|
|
|
67
68
|
"format_alignment",
|
|
68
69
|
"Mutation",
|
|
69
70
|
"AlignmentResult",
|
|
71
|
+
# Genome mapper (v3.0)
|
|
72
|
+
"GenomeAligner",
|
|
73
|
+
"Mapping",
|
|
70
74
|
# Pipeline
|
|
71
75
|
"run",
|
|
72
76
|
"build_report",
|
|
@@ -17,8 +17,11 @@ _DLL_PATH = _ENGINE_DIR / ("engine.dll" if sys.platform == "win32" else "engin
|
|
|
17
17
|
# ── Tipos ctypes frecuentes ────────────────────────────────────────────────────
|
|
18
18
|
_U8P = ctypes.POINTER(ctypes.c_uint8)
|
|
19
19
|
_I32P = ctypes.POINTER(ctypes.c_int32)
|
|
20
|
+
_I64P = ctypes.POINTER(ctypes.c_int64)
|
|
21
|
+
_F64P = ctypes.POINTER(ctypes.c_double)
|
|
20
22
|
_I32 = ctypes.c_int32
|
|
21
23
|
_I64 = ctypes.c_int64
|
|
24
|
+
_F64 = ctypes.c_double
|
|
22
25
|
_CHARP = ctypes.c_char_p
|
|
23
26
|
|
|
24
27
|
# ── Carga del DLL ──────────────────────────────────────────────────────────────
|
|
@@ -200,10 +203,30 @@ def _check_libdeflate() -> None:
|
|
|
200
203
|
pass
|
|
201
204
|
|
|
202
205
|
|
|
206
|
+
C_CHAIN_AVAILABLE: bool = False
|
|
207
|
+
|
|
208
|
+
def _check_chain() -> None:
|
|
209
|
+
"""DP de chaining del alineador de genomas (v3). Opcional."""
|
|
210
|
+
global C_CHAIN_AVAILABLE
|
|
211
|
+
if not C_AVAILABLE or _lib is None:
|
|
212
|
+
return
|
|
213
|
+
try:
|
|
214
|
+
_lib.bio_chain_dp.restype = None
|
|
215
|
+
_lib.bio_chain_dp.argtypes = [
|
|
216
|
+
_I64P, _I64P, _I32, # x, y, n
|
|
217
|
+
_I32, _I64, _I32, _F64, # k, max_gap, window, gap_w
|
|
218
|
+
_F64P, _I32P, # f (out), prev (out)
|
|
219
|
+
]
|
|
220
|
+
C_CHAIN_AVAILABLE = True
|
|
221
|
+
except (AttributeError, OSError):
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
|
|
203
225
|
_check_parser()
|
|
204
226
|
_check_batch()
|
|
205
227
|
_check_parallel()
|
|
206
228
|
_check_libdeflate()
|
|
229
|
+
_check_chain()
|
|
207
230
|
|
|
208
231
|
|
|
209
232
|
# ── Wrappers Python ────────────────────────────────────────────────────────────
|
|
@@ -324,6 +347,23 @@ def c_nw_banded(
|
|
|
324
347
|
)
|
|
325
348
|
|
|
326
349
|
|
|
350
|
+
def c_chain_dp(xs: np.ndarray, ys: np.ndarray, k: int,
|
|
351
|
+
max_gap: int, window: int, gap_w: float):
|
|
352
|
+
"""DP de chaining en C. Rellena y devuelve (f, prev).
|
|
353
|
+
|
|
354
|
+
``xs``/``ys`` deben ser int64 contiguos y estar ordenados por (x, y).
|
|
355
|
+
"""
|
|
356
|
+
n = int(xs.size)
|
|
357
|
+
f = np.empty(n, dtype=np.float64)
|
|
358
|
+
prev = np.empty(n, dtype=np.int32)
|
|
359
|
+
_lib.bio_chain_dp(
|
|
360
|
+
xs.ctypes.data_as(_I64P), ys.ctypes.data_as(_I64P), _I32(n),
|
|
361
|
+
_I32(int(k)), _I64(int(max_gap)), _I32(int(window)), _F64(float(gap_w)),
|
|
362
|
+
f.ctypes.data_as(_F64P), prev.ctypes.data_as(_I32P),
|
|
363
|
+
)
|
|
364
|
+
return f, prev
|
|
365
|
+
|
|
366
|
+
|
|
327
367
|
def c_parser_open(path: str) -> int:
|
|
328
368
|
"""Abre un archivo FASTA/FASTQ y devuelve un handle opaco (c_void_p)."""
|
|
329
369
|
raw = path.encode("utf-8") if isinstance(path, str) else path
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
#include <stdlib.h>
|
|
22
22
|
#include <string.h>
|
|
23
23
|
#include <stdint.h>
|
|
24
|
+
#include <math.h>
|
|
24
25
|
|
|
25
26
|
/* ── Lectura de archivos: gzip transparente si se compila con -DBIO_USE_ZLIB ──
|
|
26
27
|
zlib gzopen/gzread leen tanto archivos planos como .gz (autodetección del
|
|
@@ -1536,3 +1537,45 @@ EXPORT int32_t bio_parse_mem_parallel(
|
|
|
1536
1537
|
free(outs);
|
|
1537
1538
|
return (rc != 0) ? rc : total_rec;
|
|
1538
1539
|
}
|
|
1540
|
+
|
|
1541
|
+
/* ── Chaining DP (alineador v3) ───────────────────────────────────────────────
|
|
1542
|
+
Rellena f[] (mejor score de cadena que TERMINA en i) y prev[] (predecesor)
|
|
1543
|
+
para anclas ordenadas ascendentemente por (x, y). El backtracking y la
|
|
1544
|
+
supresión de solapamientos se hacen en Python (baratos, O(n)).
|
|
1545
|
+
|
|
1546
|
+
x, y : posiciones de las anclas (ref y coord. de chaining), int64.
|
|
1547
|
+
n : nº de anclas. k : peso del ancla (longitud del k-mer).
|
|
1548
|
+
max_gap : distancia máxima entre anclas encadenables.
|
|
1549
|
+
window : nº de predecesores examinados por ancla.
|
|
1550
|
+
gap_w : peso lineal de la penalización por hueco.
|
|
1551
|
+
|
|
1552
|
+
Réplica exacta del DP vectorizado de genomemap.py (misma fórmula y mismo
|
|
1553
|
+
desempate: al iterar j de i-1 hacia abajo con `>` estricto, en empate gana
|
|
1554
|
+
el predecesor MÁS CERCANO). Así el resultado C == fallback NumPy. */
|
|
1555
|
+
EXPORT void bio_chain_dp(const int64_t* x, const int64_t* y, int32_t n,
|
|
1556
|
+
int32_t k, int64_t max_gap, int32_t window,
|
|
1557
|
+
double gap_w, double* f, int32_t* prev) {
|
|
1558
|
+
for (int32_t i = 0; i < n; i++) {
|
|
1559
|
+
double best = (double)k;
|
|
1560
|
+
int32_t bp = -1;
|
|
1561
|
+
int32_t lo = i - window;
|
|
1562
|
+
if (lo < 0) lo = 0;
|
|
1563
|
+
for (int32_t j = i - 1; j >= lo; j--) {
|
|
1564
|
+
int64_t dx = x[i] - x[j];
|
|
1565
|
+
if (dx > max_gap) break; /* ordenado → resto aún más lejos */
|
|
1566
|
+
int64_t dy = y[i] - y[j];
|
|
1567
|
+
if (dy <= 0 || dx <= 0 || dy > max_gap) continue;
|
|
1568
|
+
int64_t gap = dx - dy;
|
|
1569
|
+
if (gap < 0) gap = -gap;
|
|
1570
|
+
if (gap > max_gap) continue;
|
|
1571
|
+
int64_t mn = (dx < dy) ? dx : dy;
|
|
1572
|
+
double match = (double)((mn < (int64_t)k) ? mn : (int64_t)k);
|
|
1573
|
+
double cost = gap_w * (double)gap;
|
|
1574
|
+
if (gap > 0) cost += log2((double)gap + 1.0);
|
|
1575
|
+
double sc = f[j] + match - cost;
|
|
1576
|
+
if (sc > best) { best = sc; bp = j; }
|
|
1577
|
+
}
|
|
1578
|
+
f[i] = best;
|
|
1579
|
+
prev[i] = bp;
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""
|
|
2
|
+
genomemap.py
|
|
3
|
+
══════════════════════════════════════════════════════════════════════
|
|
4
|
+
Alineador de genomas / mapeador de reads largos (v3.0) — el buque
|
|
5
|
+
insignia: "seed-chain-align" completo, la estrategia de minimap2/BWA.
|
|
6
|
+
|
|
7
|
+
Fases (todas aquí montadas sobre minimizers.py + refindex.py):
|
|
8
|
+
3. SEEDING — anclas (read↔referencia) en ambas hebras.
|
|
9
|
+
4. CHAINING — DP sobre anclas → mejor cadena colineal.
|
|
10
|
+
5. EXTENSIÓN — alineamiento base a base (banded) en la región de la cadena.
|
|
11
|
+
6. API — GenomeAligner.map(read) → lista de Mapping (formato tipo PAF).
|
|
12
|
+
|
|
13
|
+
Idea central
|
|
14
|
+
────────────
|
|
15
|
+
No se alinea "todo contra todo" (imposible a escala de genoma). Se buscan
|
|
16
|
+
anclas (k-mers compartidos), se encadenan las colineales para localizar la
|
|
17
|
+
región, y solo ahí se corre el DP exacto en una banda estrecha.
|
|
18
|
+
|
|
19
|
+
Diagonal
|
|
20
|
+
────────
|
|
21
|
+
Para anclas en hebra directa, diagonal = ref_pos − read_pos: las que la
|
|
22
|
+
comparten están en línea. Para hebra inversa se transforma la coordenada
|
|
23
|
+
del read (yc = (Lr−k) − read_pos) para que el mismo chaining valga igual.
|
|
24
|
+
|
|
25
|
+
Nota de rendimiento
|
|
26
|
+
───────────────────
|
|
27
|
+
El chaining es un DP sobre ANCLAS (no por símbolo): un bucle acotado por
|
|
28
|
+
ventana de predecesores. Es el sitio natural para bajar a C más adelante;
|
|
29
|
+
en Python ya es correcto y rápido para el nº de anclas habitual.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from typing import NamedTuple, Optional
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
|
|
38
|
+
from .aligner import SequenceAligner
|
|
39
|
+
from .biocore import SeqType, SmartImporter
|
|
40
|
+
from .engine._loader import C_CHAIN_AVAILABLE, c_chain_dp
|
|
41
|
+
from .minimizers import encode_bases, minimizers
|
|
42
|
+
from .refindex import ReferenceIndex
|
|
43
|
+
|
|
44
|
+
_RC = str.maketrans("ACGTacgt", "TGCAtgca")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _revcomp(s: str) -> str:
|
|
48
|
+
return s.translate(_RC)[::-1]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
52
|
+
# §3 SEEDING — anclas
|
|
53
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
54
|
+
|
|
55
|
+
class Anchors(NamedTuple):
|
|
56
|
+
"""Coincidencias k-mer read↔referencia. Todo vectorizado.
|
|
57
|
+
|
|
58
|
+
ref_pos : int64 — posición del k-mer en la referencia.
|
|
59
|
+
read_pos : int64 — posición del k-mer en el read (coords. directas).
|
|
60
|
+
strand : uint8 — 0 = hebra directa, 1 = inversa (read↔ref).
|
|
61
|
+
"""
|
|
62
|
+
ref_pos: np.ndarray
|
|
63
|
+
read_pos: np.ndarray
|
|
64
|
+
strand: np.ndarray
|
|
65
|
+
read_len: int
|
|
66
|
+
k: int
|
|
67
|
+
|
|
68
|
+
def __len__(self) -> int:
|
|
69
|
+
return int(self.ref_pos.size)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def seed(index: ReferenceIndex, read_codes: np.ndarray) -> Anchors:
|
|
73
|
+
"""Genera las anclas de un read contra el índice (ambas hebras)."""
|
|
74
|
+
mk = minimizers(read_codes, k=index.k, w=index.w)
|
|
75
|
+
res = index.lookup(mk.hashes)
|
|
76
|
+
q_pos = mk.positions[res.query_idx]
|
|
77
|
+
q_str = mk.strands[res.query_idx]
|
|
78
|
+
rel = (q_str ^ res.ref_strands).astype(np.uint8) # hebra relativa
|
|
79
|
+
return Anchors(res.ref_positions, q_pos, rel,
|
|
80
|
+
read_len=int(read_codes.size), k=index.k)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
84
|
+
# §4 CHAINING — DP sobre anclas
|
|
85
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
86
|
+
|
|
87
|
+
class Chain(NamedTuple):
|
|
88
|
+
score: float
|
|
89
|
+
strand: int # 0 directa, 1 inversa
|
|
90
|
+
ref_start: int
|
|
91
|
+
ref_end: int
|
|
92
|
+
read_start: int
|
|
93
|
+
read_end: int
|
|
94
|
+
n_anchors: int
|
|
95
|
+
anchor_ref: np.ndarray # posiciones ref de las anclas (ordenadas)
|
|
96
|
+
anchor_read: np.ndarray # posiciones read de las anclas
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Parámetros de chaining (razonables; ajustables).
|
|
100
|
+
_MAX_GAP = 5000 # distancia máxima entre anclas consecutivas
|
|
101
|
+
_WINDOW = 64 # nº de predecesores que se examinan por ancla
|
|
102
|
+
_GAP_W = 0.2 # peso lineal de la penalización por hueco
|
|
103
|
+
_MIN_ANCHORS = 2
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _chain_fill_numpy(xs: np.ndarray, ys: np.ndarray, k: int):
|
|
107
|
+
"""Fallback NumPy del DP de chaining (idéntico al C bio_chain_dp).
|
|
108
|
+
|
|
109
|
+
Bucle EXTERNO secuencial (f[i] depende de f[j<i]); bucle INTERNO sobre la
|
|
110
|
+
ventana de predecesores vectorizado. En empate gana el predecesor más
|
|
111
|
+
cercano (mismo desempate que el C).
|
|
112
|
+
"""
|
|
113
|
+
n = xs.size
|
|
114
|
+
f = np.full(n, float(k), dtype=np.float64)
|
|
115
|
+
prev = np.full(n, -1, dtype=np.int64)
|
|
116
|
+
kf = float(k)
|
|
117
|
+
for i in range(n):
|
|
118
|
+
lo = i - _WINDOW
|
|
119
|
+
if lo < 0:
|
|
120
|
+
lo = 0
|
|
121
|
+
if lo == i: # i == 0: sin predecesores
|
|
122
|
+
continue
|
|
123
|
+
dx = xs[i] - xs[lo:i]
|
|
124
|
+
dy = ys[i] - ys[lo:i]
|
|
125
|
+
gap = np.abs(dx - dy)
|
|
126
|
+
valid = ((dx > 0) & (dy > 0) & (dx <= _MAX_GAP)
|
|
127
|
+
& (dy <= _MAX_GAP) & (gap <= _MAX_GAP))
|
|
128
|
+
if not valid.any():
|
|
129
|
+
continue
|
|
130
|
+
match = np.minimum(np.minimum(dx, dy), k).astype(np.float64)
|
|
131
|
+
logterm = np.where(gap > 0, np.log2(gap + 1.0), 0.0)
|
|
132
|
+
sc = np.where(valid, f[lo:i] + match - (_GAP_W * gap + logterm), -np.inf)
|
|
133
|
+
b = sc.size - 1 - int(sc[::-1].argmax()) # empate → predecesor cercano
|
|
134
|
+
if sc[b] > kf:
|
|
135
|
+
f[i], prev[i] = float(sc[b]), lo + b
|
|
136
|
+
return f, prev
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _chain_one(x: np.ndarray, y: np.ndarray, k: int,
|
|
140
|
+
min_score: float) -> list[tuple]:
|
|
141
|
+
"""DP de chaining sobre un conjunto de anclas de UNA hebra.
|
|
142
|
+
|
|
143
|
+
x = ref_pos, y = coordenada de chaining (ambas crecen a lo largo de una
|
|
144
|
+
alineación válida). Devuelve caminos (listas de índices) por score desc.
|
|
145
|
+
"""
|
|
146
|
+
n = x.size
|
|
147
|
+
if n == 0:
|
|
148
|
+
return []
|
|
149
|
+
order = np.lexsort((y, x)) # ordenar por x, luego y
|
|
150
|
+
xs = np.ascontiguousarray(x[order], dtype=np.int64)
|
|
151
|
+
ys = np.ascontiguousarray(y[order], dtype=np.int64)
|
|
152
|
+
|
|
153
|
+
# Relleno del DP: C si está disponible (10-50× más rápido), si no NumPy.
|
|
154
|
+
if C_CHAIN_AVAILABLE:
|
|
155
|
+
f, prev = c_chain_dp(xs, ys, k, _MAX_GAP, _WINDOW, _GAP_W)
|
|
156
|
+
prev = prev.astype(np.int64)
|
|
157
|
+
else:
|
|
158
|
+
f, prev = _chain_fill_numpy(xs, ys, k)
|
|
159
|
+
|
|
160
|
+
# Backtrack de cadenas no solapadas, por score descendente.
|
|
161
|
+
used = np.zeros(n, dtype=bool)
|
|
162
|
+
paths: list[tuple] = []
|
|
163
|
+
for start in np.argsort(-f):
|
|
164
|
+
if f[start] < min_score:
|
|
165
|
+
break # f desc: el resto también cae
|
|
166
|
+
if used[start]:
|
|
167
|
+
continue
|
|
168
|
+
path = []
|
|
169
|
+
i = int(start)
|
|
170
|
+
while i != -1 and not used[i]:
|
|
171
|
+
used[i] = True
|
|
172
|
+
path.append(i)
|
|
173
|
+
i = int(prev[i])
|
|
174
|
+
# Score REAL del fragmento extraído: f[start] menos el score acumulado
|
|
175
|
+
# hasta el punto donde se cortó (una ancla ya usada, o el inicio). Sin
|
|
176
|
+
# esto, un fragmento corto (cola de otra cadena) heredaría el score
|
|
177
|
+
# inflado de la cadena larga.
|
|
178
|
+
frag = float(f[start]) - (float(f[i]) if i != -1 else 0.0)
|
|
179
|
+
if len(path) >= _MIN_ANCHORS and frag >= min_score:
|
|
180
|
+
path.reverse() # de inicio a fin
|
|
181
|
+
paths.append((frag, order[np.array(path)]))
|
|
182
|
+
return paths
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def chain(anchors: Anchors, min_score: float = 40.0) -> list[Chain]:
|
|
186
|
+
"""Encadena las anclas en cadenas colineales, mejor primero."""
|
|
187
|
+
if len(anchors) == 0:
|
|
188
|
+
return []
|
|
189
|
+
k = anchors.k
|
|
190
|
+
Lr = anchors.read_len
|
|
191
|
+
out: list[Chain] = []
|
|
192
|
+
for strand in (0, 1):
|
|
193
|
+
m = anchors.strand == strand
|
|
194
|
+
if not m.any():
|
|
195
|
+
continue
|
|
196
|
+
rp = anchors.ref_pos[m]
|
|
197
|
+
qp = anchors.read_pos[m]
|
|
198
|
+
# coordenada de chaining: directa = qp; inversa = (Lr-k) - qp
|
|
199
|
+
yc = qp if strand == 0 else (Lr - k) - qp
|
|
200
|
+
for score, idx in _chain_one(rp, yc, k, min_score):
|
|
201
|
+
# idx: índices (en el orden original de esta hebra) de la cadena
|
|
202
|
+
a_ref = rp[idx]
|
|
203
|
+
a_read = qp[idx]
|
|
204
|
+
srt = np.argsort(a_ref)
|
|
205
|
+
a_ref, a_read = a_ref[srt], a_read[srt]
|
|
206
|
+
out.append(Chain(
|
|
207
|
+
score=score, strand=strand,
|
|
208
|
+
ref_start=int(a_ref.min()), ref_end=int(a_ref.max()) + k,
|
|
209
|
+
read_start=int(a_read.min()), read_end=int(a_read.max()) + k,
|
|
210
|
+
n_anchors=int(a_ref.size),
|
|
211
|
+
anchor_ref=a_ref, anchor_read=a_read,
|
|
212
|
+
))
|
|
213
|
+
out.sort(key=lambda c: c.score, reverse=True)
|
|
214
|
+
|
|
215
|
+
# Supresión de solapamientos: una cadena de menor score que solapa >50% en
|
|
216
|
+
# el GENOMA con una ya aceptada (misma hebra) es un fragmento redundante del
|
|
217
|
+
# mismo locus → se descarta. Loci distintos (copias) tienen spans separados
|
|
218
|
+
# y se conservan ambos.
|
|
219
|
+
kept: list[Chain] = []
|
|
220
|
+
for c in out:
|
|
221
|
+
redundant = False
|
|
222
|
+
for kc in kept:
|
|
223
|
+
if c.strand != kc.strand:
|
|
224
|
+
continue
|
|
225
|
+
inter = min(c.ref_end, kc.ref_end) - max(c.ref_start, kc.ref_start)
|
|
226
|
+
shorter = min(c.ref_end - c.ref_start, kc.ref_end - kc.ref_start)
|
|
227
|
+
if shorter > 0 and inter > 0.5 * shorter:
|
|
228
|
+
redundant = True
|
|
229
|
+
break
|
|
230
|
+
if not redundant:
|
|
231
|
+
kept.append(c)
|
|
232
|
+
return kept
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
236
|
+
# §5-6 EXTENSIÓN + API
|
|
237
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
238
|
+
|
|
239
|
+
class Mapping(NamedTuple):
|
|
240
|
+
"""Un mapeo de un read en la referencia (campos estilo PAF)."""
|
|
241
|
+
query_len: int
|
|
242
|
+
query_start: int
|
|
243
|
+
query_end: int
|
|
244
|
+
strand: str # '+' o '-'
|
|
245
|
+
target_len: int
|
|
246
|
+
target_start: int
|
|
247
|
+
target_end: int
|
|
248
|
+
num_matches: int # residuos idénticos en el bloque alineado
|
|
249
|
+
block_len: int # longitud del bloque de alineamiento
|
|
250
|
+
mapq: int
|
|
251
|
+
identity: float
|
|
252
|
+
chain_score: float
|
|
253
|
+
cigar: Optional[str]
|
|
254
|
+
|
|
255
|
+
def to_paf(self, query_name: str = "query", target_name: str = "ref") -> str:
|
|
256
|
+
"""Serializa a una línea PAF (el formato de minimap2)."""
|
|
257
|
+
fields = [
|
|
258
|
+
query_name, self.query_len, self.query_start, self.query_end,
|
|
259
|
+
self.strand, target_name, self.target_len,
|
|
260
|
+
self.target_start, self.target_end,
|
|
261
|
+
self.num_matches, self.block_len, self.mapq,
|
|
262
|
+
]
|
|
263
|
+
line = "\t".join(str(f) for f in fields)
|
|
264
|
+
if self.cigar:
|
|
265
|
+
line += f"\tcg:Z:{self.cigar}"
|
|
266
|
+
return line
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _pack(seq: str):
|
|
270
|
+
return SmartImporter.from_string(f">x\n{seq}\n",
|
|
271
|
+
force_type=SeqType.NUCLEOTIDE)[0]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _cigar(aln_ref: str, aln_read: str) -> tuple[str, int, int]:
|
|
275
|
+
"""CIGAR + (nº matches, longitud de bloque) desde dos cadenas alineadas.
|
|
276
|
+
|
|
277
|
+
aln_ref/aln_read llevan '-' en los huecos. M=col sin hueco, I=inserción
|
|
278
|
+
en el read (hueco en ref), D=deleción (hueco en el read).
|
|
279
|
+
"""
|
|
280
|
+
a = np.frombuffer(aln_ref.encode("ascii"), dtype=np.uint8)
|
|
281
|
+
b = np.frombuffer(aln_read.encode("ascii"), dtype=np.uint8)
|
|
282
|
+
if a.size == 0:
|
|
283
|
+
return "", 0, 0
|
|
284
|
+
gap = np.uint8(ord("-"))
|
|
285
|
+
ag, bg = a == gap, b == gap
|
|
286
|
+
n_match = int(np.count_nonzero(~ag & ~bg & (a == b)))
|
|
287
|
+
# op: 0=M, 1=I (hueco en ref), 2=D (hueco en el read) — vectorizado
|
|
288
|
+
op = np.where(ag, 1, np.where(bg, 2, 0)).astype(np.int8)
|
|
289
|
+
change = np.ones(op.size, dtype=bool)
|
|
290
|
+
change[1:] = op[1:] != op[:-1]
|
|
291
|
+
starts = np.flatnonzero(change)
|
|
292
|
+
runs = np.diff(np.append(starts, op.size))
|
|
293
|
+
# el join recorre solo los TRAMOS (pocos), no las columnas
|
|
294
|
+
cigar = "".join(f"{int(r)}{'MID'[int(op[s])]}"
|
|
295
|
+
for r, s in zip(runs, starts, strict=True))
|
|
296
|
+
return cigar, n_match, int(op.size)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _extend(ref: str, read: str, ch: Chain) -> Optional[Mapping]:
|
|
300
|
+
"""Fase 5: alinea (banded) la región de la cadena y arma el Mapping."""
|
|
301
|
+
ref_sub = ref[ch.ref_start:ch.ref_end]
|
|
302
|
+
read_sub = read[ch.read_start:ch.read_end]
|
|
303
|
+
if ch.strand == 1:
|
|
304
|
+
read_sub = _revcomp(read_sub)
|
|
305
|
+
if not ref_sub or not read_sub:
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
band = min(256, abs(len(ref_sub) - len(read_sub)) + 32)
|
|
309
|
+
try:
|
|
310
|
+
res = SequenceAligner.align(_pack(ref_sub), _pack(read_sub),
|
|
311
|
+
mode="global", band=band)
|
|
312
|
+
cigar, n_match, block = _cigar(res.aligned_a, res.aligned_b)
|
|
313
|
+
identity = res.identity
|
|
314
|
+
except Exception: # noqa: BLE001 — extensión best-effort
|
|
315
|
+
cigar, n_match, block, identity = None, ch.n_anchors * ch.k, \
|
|
316
|
+
ch.ref_end - ch.ref_start, 1.0
|
|
317
|
+
|
|
318
|
+
return Mapping(
|
|
319
|
+
query_len=len(read),
|
|
320
|
+
query_start=ch.read_start, query_end=ch.read_end,
|
|
321
|
+
strand="+" if ch.strand == 0 else "-",
|
|
322
|
+
target_len=len(ref),
|
|
323
|
+
target_start=ch.ref_start, target_end=ch.ref_end,
|
|
324
|
+
num_matches=n_match, block_len=block,
|
|
325
|
+
mapq=0, identity=identity, chain_score=ch.score, cigar=cigar,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _mapq(chains: list[Chain], i: int) -> int:
|
|
330
|
+
"""Calidad de mapeo simple: primaria alta si domina a la secundaria."""
|
|
331
|
+
if i > 0:
|
|
332
|
+
return 5
|
|
333
|
+
if len(chains) < 2:
|
|
334
|
+
return 60
|
|
335
|
+
ratio = chains[1].score / chains[0].score if chains[0].score else 0.0
|
|
336
|
+
return int(max(0, min(60, round(60 * (1.0 - ratio)))))
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class GenomeAligner:
|
|
340
|
+
"""Mapeador de reads contra una referencia (seed-chain-align)."""
|
|
341
|
+
|
|
342
|
+
def __init__(self, reference: str, k: int = 15, w: int = 10,
|
|
343
|
+
max_occ: Optional[int] = 50, name: str = "ref"):
|
|
344
|
+
self.reference = reference.upper()
|
|
345
|
+
self.name = name
|
|
346
|
+
self.index = ReferenceIndex.from_sequence(self.reference, k=k, w=w,
|
|
347
|
+
max_occ=max_occ)
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def k(self) -> int:
|
|
351
|
+
return self.index.k
|
|
352
|
+
|
|
353
|
+
def map(self, read: str, min_chain_score: float = 40.0,
|
|
354
|
+
max_hits: int = 5) -> list[Mapping]:
|
|
355
|
+
"""Mapea un read → lista de Mapping (primaria primero)."""
|
|
356
|
+
read = read.upper()
|
|
357
|
+
anchors = seed(self.index, encode_bases(read))
|
|
358
|
+
chains = chain(anchors, min_score=min_chain_score)[:max_hits]
|
|
359
|
+
mappings: list[Mapping] = []
|
|
360
|
+
for i, ch in enumerate(chains):
|
|
361
|
+
mp = _extend(self.reference, read, ch)
|
|
362
|
+
if mp is not None:
|
|
363
|
+
mappings.append(mp._replace(mapq=_mapq(chains, i)))
|
|
364
|
+
return mappings
|