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.
Files changed (38) hide show
  1. {bioforge-2.3.0 → bioforge-3.0.0}/PKG-INFO +44 -1
  2. {bioforge-2.3.0 → bioforge-3.0.0}/README.md +43 -0
  3. {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/__init__.py +5 -1
  4. {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/engine/_loader.py +40 -0
  5. {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/engine/engine.c +43 -0
  6. {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/engine/engine.dll +0 -0
  7. bioforge-3.0.0/bioforge/genomemap.py +364 -0
  8. bioforge-3.0.0/bioforge/minimizers.py +148 -0
  9. bioforge-3.0.0/bioforge/refindex.py +111 -0
  10. {bioforge-2.3.0 → bioforge-3.0.0}/bioforge.egg-info/PKG-INFO +44 -1
  11. {bioforge-2.3.0 → bioforge-3.0.0}/bioforge.egg-info/SOURCES.txt +6 -0
  12. {bioforge-2.3.0 → bioforge-3.0.0}/pyproject.toml +7 -3
  13. bioforge-3.0.0/tests/test_genomemap.py +144 -0
  14. bioforge-3.0.0/tests/test_minimizers.py +133 -0
  15. bioforge-3.0.0/tests/test_refindex.py +98 -0
  16. {bioforge-2.3.0 → bioforge-3.0.0}/LICENSE +0 -0
  17. {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/aligner.py +0 -0
  18. {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/analyze.py +0 -0
  19. {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/bgzf.py +0 -0
  20. {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/biocore.py +0 -0
  21. {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/engine/__init__.py +0 -0
  22. {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/engine/build.py +0 -0
  23. {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/qcreport.py +0 -0
  24. {bioforge-2.3.0 → bioforge-3.0.0}/bioforge/smart_translator.py +0 -0
  25. {bioforge-2.3.0 → bioforge-3.0.0}/bioforge.egg-info/dependency_links.txt +0 -0
  26. {bioforge-2.3.0 → bioforge-3.0.0}/bioforge.egg-info/entry_points.txt +0 -0
  27. {bioforge-2.3.0 → bioforge-3.0.0}/bioforge.egg-info/requires.txt +0 -0
  28. {bioforge-2.3.0 → bioforge-3.0.0}/bioforge.egg-info/top_level.txt +0 -0
  29. {bioforge-2.3.0 → bioforge-3.0.0}/setup.cfg +0 -0
  30. {bioforge-2.3.0 → bioforge-3.0.0}/setup.py +0 -0
  31. {bioforge-2.3.0 → bioforge-3.0.0}/tests/test_aligner.py +0 -0
  32. {bioforge-2.3.0 → bioforge-3.0.0}/tests/test_analyze.py +0 -0
  33. {bioforge-2.3.0 → bioforge-3.0.0}/tests/test_bgzf.py +0 -0
  34. {bioforge-2.3.0 → bioforge-3.0.0}/tests/test_biocore.py +0 -0
  35. {bioforge-2.3.0 → bioforge-3.0.0}/tests/test_errors.py +0 -0
  36. {bioforge-2.3.0 → bioforge-3.0.0}/tests/test_qcreport.py +0 -0
  37. {bioforge-2.3.0 → bioforge-3.0.0}/tests/test_streaming.py +0 -0
  38. {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: 2.3.0
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__ = "2.3.0"
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
+ }
@@ -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